From 237c4d964214f6592220dc5efdf7fc2e16af297f Mon Sep 17 00:00:00 2001 From: patrick thornton <70873350+patrickthornton@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:25:20 -0400 Subject: [PATCH 01/14] fix(openapi): skip inherited props and propagate optionality in discriminated-union base inference (#16183) fix(openapi): skip inherited and propagate optionality in inferred discriminated-union base properties When `infer-discriminated-union-base-properties: true`, the OpenAPI parser no longer (1) lifts properties every variant already inherits via a shared `allOf $ref` parent, and (2) emits an inferred common property as required when any variant declares it not-required. Prevents TypeScript SDK `_Base` interfaces from colliding with the real parent on either presence or optionality (TS2320). --- ...erDiscriminatedUnionBaseProperties.test.ts | 297 ++++++++++++++++++ .../src/schema/convertDiscriminatedOneOf.ts | 174 +++++++++- .../oneof-shared-allof-properties.json | 45 +-- .../oneof-shared-allof-properties.json | 38 +-- ...iminated-union-base-properties-overlap.yml | 10 + 5 files changed, 491 insertions(+), 73 deletions(-) create mode 100644 packages/cli/api-importers/openapi/openapi-ir-parser/src/__test__/inferDiscriminatedUnionBaseProperties.test.ts create mode 100644 packages/cli/cli/changes/unreleased/fix-infer-discriminated-union-base-properties-overlap.yml diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/__test__/inferDiscriminatedUnionBaseProperties.test.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/__test__/inferDiscriminatedUnionBaseProperties.test.ts new file mode 100644 index 000000000000..fdd7aff36981 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/__test__/inferDiscriminatedUnionBaseProperties.test.ts @@ -0,0 +1,297 @@ +import { Schema, Source } from "@fern-api/openapi-ir"; +import { TaskContext } from "@fern-api/task-context"; +import { OpenAPIV3 } from "openapi-types"; +import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_PARSE_OPENAPI_SETTINGS } from "../options.js"; +import { parse } from "../parse.js"; + +function mockTaskContext(): TaskContext { + return { + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + log: vi.fn() + } + } as unknown as TaskContext; +} + +function findRootSchema(ir: ReturnType, name: string): Schema | undefined { + return ir.groupedSchemas.rootSchemas[name]; +} + +function commonPropertyKeysOf(schema: Schema | undefined): string[] { + if (schema == null || schema.type !== "oneOf") { + return []; + } + if (schema.value.type !== "discriminated") { + return []; + } + return schema.value.commonProperties.map((p) => p.key); +} + +function commonPropertyOptionalityOf(schema: Schema | undefined, key: string): "required" | "optional" | "missing" { + if (schema == null || schema.type !== "oneOf" || schema.value.type !== "discriminated") { + return "missing"; + } + const prop = schema.value.commonProperties.find((p) => p.key === key); + if (prop == null) { + return "missing"; + } + return prop.schema.type === "optional" ? "optional" : "required"; +} + +describe("infer-discriminated-union-base-properties", () => { + it("does NOT infer properties already inherited via a shared allOf $ref parent", () => { + const doc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test API", version: "1.0" }, + paths: { + "/things": { + get: { + operationId: "listThings", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/MyUnion" } + } + } + } + } + } + } + }, + components: { + schemas: { + Common: { + type: "object", + properties: { + sharedField: { type: "string" }, + anotherShared: { type: "integer" } + } + // no `required` — both fields are optional in Common + }, + VariantA: { + allOf: [ + { $ref: "#/components/schemas/Common" }, + { + type: "object", + properties: { + kind: { type: "string", enum: ["a"] }, + ownPropA: { type: "string" } + }, + required: ["kind"] + } + ] + }, + VariantB: { + allOf: [ + { $ref: "#/components/schemas/Common" }, + { + type: "object", + properties: { + kind: { type: "string", enum: ["b"] }, + ownPropB: { type: "string" } + }, + required: ["kind"] + } + ] + }, + MyUnion: { + type: "object", + oneOf: [{ $ref: "#/components/schemas/VariantA" }, { $ref: "#/components/schemas/VariantB" }], + discriminator: { + propertyName: "kind", + mapping: { + a: "#/components/schemas/VariantA", + b: "#/components/schemas/VariantB" + } + } + } + } + } + }; + + const ir = parse({ + context: mockTaskContext(), + documents: [ + { + type: "openapi", + value: doc, + source: Source.openapi({ file: "test.yml" }), + settings: { + ...DEFAULT_PARSE_OPENAPI_SETTINGS, + shouldInferDiscriminatedUnionBaseProperties: true + } + } + ] + }); + + const union = findRootSchema(ir, "MyUnion"); + const keys = commonPropertyKeysOf(union); + + // `sharedField` and `anotherShared` come from Common, which every variant inherits + // via `allOf: $ref`. They should NOT be re-emitted as union commonProperties. + expect(keys).not.toContain("sharedField"); + expect(keys).not.toContain("anotherShared"); + }); + + it("still infers properties that variants declare inline (not inherited from a shared parent)", () => { + const doc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test API", version: "1.0" }, + paths: { + "/things": { + get: { + operationId: "listThings", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/MyUnion" } + } + } + } + } + } + } + }, + components: { + schemas: { + VariantA: { + type: "object", + properties: { + kind: { type: "string", enum: ["a"] }, + inlineShared: { type: "string" }, + ownPropA: { type: "string" } + }, + required: ["kind", "inlineShared"] + }, + VariantB: { + type: "object", + properties: { + kind: { type: "string", enum: ["b"] }, + inlineShared: { type: "string" }, + ownPropB: { type: "string" } + }, + required: ["kind", "inlineShared"] + }, + MyUnion: { + type: "object", + oneOf: [{ $ref: "#/components/schemas/VariantA" }, { $ref: "#/components/schemas/VariantB" }], + discriminator: { + propertyName: "kind", + mapping: { + a: "#/components/schemas/VariantA", + b: "#/components/schemas/VariantB" + } + } + } + } + } + }; + + const ir = parse({ + context: mockTaskContext(), + documents: [ + { + type: "openapi", + value: doc, + source: Source.openapi({ file: "test.yml" }), + settings: { + ...DEFAULT_PARSE_OPENAPI_SETTINGS, + shouldInferDiscriminatedUnionBaseProperties: true + } + } + ] + }); + + const union = findRootSchema(ir, "MyUnion"); + const keys = commonPropertyKeysOf(union); + + // `inlineShared` is declared inline on each variant and not inherited from a shared parent, + // so the inference should still pick it up. + expect(keys).toContain("inlineShared"); + }); + + it("lifts an inferred property as optional when any variant declares it optional", () => { + const doc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test API", version: "1.0" }, + paths: { + "/things": { + get: { + operationId: "listThings", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/MyUnion" } + } + } + } + } + } + } + }, + components: { + schemas: { + VariantA: { + type: "object", + properties: { + kind: { type: "string", enum: ["a"] }, + sometimesRequired: { type: "string" } + }, + // sometimesRequired is OPTIONAL here + required: ["kind"] + }, + VariantB: { + type: "object", + properties: { + kind: { type: "string", enum: ["b"] }, + sometimesRequired: { type: "string" } + }, + // sometimesRequired is REQUIRED here + required: ["kind", "sometimesRequired"] + }, + MyUnion: { + type: "object", + oneOf: [{ $ref: "#/components/schemas/VariantA" }, { $ref: "#/components/schemas/VariantB" }], + discriminator: { + propertyName: "kind", + mapping: { + a: "#/components/schemas/VariantA", + b: "#/components/schemas/VariantB" + } + } + } + } + } + }; + + const ir = parse({ + context: mockTaskContext(), + documents: [ + { + type: "openapi", + value: doc, + source: Source.openapi({ file: "test.yml" }), + settings: { + ...DEFAULT_PARSE_OPENAPI_SETTINGS, + shouldInferDiscriminatedUnionBaseProperties: true + } + } + ] + }); + + const union = findRootSchema(ir, "MyUnion"); + // Lifted, but as optional — because at least one variant doesn't require it. + expect(commonPropertyOptionalityOf(union, "sometimesRequired")).toBe("optional"); + }); +}); diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/convertDiscriminatedOneOf.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/convertDiscriminatedOneOf.ts index cd00e3c09794..79fb88e87de3 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/convertDiscriminatedOneOf.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/convertDiscriminatedOneOf.ts @@ -353,6 +353,12 @@ export function wrapDiscriminatedOneOf({ * all variants. The discriminant property and any properties already declared at the * union's top level are excluded. This lets SDKs expose shared fields directly on * the union type instead of forcing a cast to a concrete variant. + * + * Properties that every variant already inherits through a shared `allOf $ref` parent + * are also excluded: the parent schema is the single source of truth, and re-emitting + * those properties on the union forces some generators (e.g. TypeScript) to declare a + * synthesized base interface alongside the real parent, which can collide when the two + * disagree on optionality. */ function inferCommonPropertiesFromVariants({ variants, @@ -381,6 +387,16 @@ function inferCommonPropertiesFromVariants({ if (firstVariantProps == null) { return []; } + const inheritedFromSharedParent = getPropertyNamesInheritedFromSharedAllOfRefParents({ + variants, + context, + breadcrumbs, + source, + namespace + }); + const variantRequiredSets = variants.map((variant) => + getAllRequiredPropertyNames({ schema: variant, context, visited: new Set() }) + ); const result: CommonPropertyWithExample[] = []; for (const [propertyName, firstSchema] of Object.entries(firstVariantProps)) { if (propertyName === discriminant) { @@ -389,6 +405,9 @@ function inferCommonPropertiesFromVariants({ if (existingPropertyNames.has(propertyName)) { continue; } + if (inheritedFromSharedParent.has(propertyName)) { + continue; + } let presentInAll = true; for (let i = 1; i < variantPropertyMaps.length; i++) { const map = variantPropertyMaps[i]; @@ -398,8 +417,159 @@ function inferCommonPropertiesFromVariants({ break; } } - if (presentInAll) { - result.push({ key: propertyName, schema: firstSchema }); + if (!presentInAll) { + continue; + } + const requiredInEveryVariant = variantRequiredSets.every((set) => set.has(propertyName)); + const schemaToLift = + requiredInEveryVariant || firstSchema.type === "optional" || firstSchema.type === "nullable" + ? firstSchema + : SchemaWithExample.optional({ + nameOverride: undefined, + generatedName: "", + title: undefined, + value: firstSchema, + description: undefined, + availability: undefined, + namespace: undefined, + groupName: undefined, + inline: undefined + }); + result.push({ key: propertyName, schema: schemaToLift }); + } + return result; +} + +/** + * Returns the set of property names that the given schema (and any schemas reachable via + * `allOf`) declares as required. A property is required for the variant if any link in the + * `allOf` chain places it in a `required` array; otherwise it's optional. Used by the + * inference path to decide whether a lifted common property should be wrapped as optional. + */ +function getAllRequiredPropertyNames({ + schema, + context, + visited +}: { + schema: OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject; + context: SchemaParserContext; + visited: Set; +}): Set { + const result = new Set(); + let resolved: OpenAPIV3.SchemaObject; + if (isReferenceObject(schema)) { + if (visited.has(schema.$ref)) { + return result; + } + visited.add(schema.$ref); + resolved = context.resolveSchemaReference(schema); + } else { + resolved = schema; + } + for (const name of resolved.required ?? []) { + result.add(name); + } + for (const allOfElement of resolved.allOf ?? []) { + const childRequired = getAllRequiredPropertyNames({ schema: allOfElement, context, visited }); + for (const name of childRequired) { + result.add(name); + } + } + return result; +} + +/** + * Walks each variant's `allOf` chain (following `$ref`s transitively) and returns the set + * of property names that come from `$ref` parents shared by *every* variant. These are the + * properties each variant already inherits from a common ancestor — re-declaring them on + * the union would cause the structural duplication described above. + */ +function getPropertyNamesInheritedFromSharedAllOfRefParents({ + variants, + context, + breadcrumbs, + source, + namespace +}: { + variants: Array; + context: SchemaParserContext; + breadcrumbs: string[]; + source: Source; + namespace: string | undefined; +}): Set { + if (variants.length === 0) { + return new Set(); + } + const refsPerVariant = variants.map((variant) => + collectTransitiveAllOfRefs({ schema: variant, context, visited: new Set() }) + ); + const firstSet = refsPerVariant[0]; + if (firstSet == null || firstSet.size === 0) { + return new Set(); + } + const inherited = new Set(); + for (const [refKey, refObject] of firstSet) { + const sharedAcrossAllVariants = refsPerVariant.every((s) => s.has(refKey)); + if (!sharedAcrossAllVariants) { + continue; + } + const parentProperties = getAllProperties({ + schema: refObject, + context, + breadcrumbs, + source, + namespace + }); + for (const propertyName of Object.keys(parentProperties)) { + inherited.add(propertyName); + } + } + return inherited; +} + +/** + * Returns a map of `$ref` URI -> the `ReferenceObject` that points to it, for every + * `$ref` reachable via `allOf` from the given schema (transitively). Inline `allOf` + * elements are recursed into so a deeply-nested `$ref` still surfaces. + */ +function collectTransitiveAllOfRefs({ + schema, + context, + visited +}: { + schema: OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject; + context: SchemaParserContext; + visited: Set; +}): Map { + const result = new Map(); + let resolved: OpenAPIV3.SchemaObject; + if (isReferenceObject(schema)) { + if (visited.has(schema.$ref)) { + return result; + } + visited.add(schema.$ref); + resolved = context.resolveSchemaReference(schema); + } else { + resolved = schema; + } + for (const allOfElement of resolved.allOf ?? []) { + if (isReferenceObject(allOfElement)) { + if (!result.has(allOfElement.$ref)) { + result.set(allOfElement.$ref, allOfElement); + } + const nested = collectTransitiveAllOfRefs({ schema: allOfElement, context, visited }); + for (const [k, v] of nested) { + if (!result.has(k)) { + result.set(k, v); + } + } + } else { + const nested = collectTransitiveAllOfRefs({ schema: allOfElement, context, visited }); + for (const [k, v] of nested) { + if (!result.has(k)) { + result.set(k, v); + } + } } } return result; diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/oneof-shared-allof-properties.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/oneof-shared-allof-properties.json index e253894243df..7ff7da39a9db 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/oneof-shared-allof-properties.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/oneof-shared-allof-properties.json @@ -77,21 +77,21 @@ "value": { "id": { "value": { - "value": "string", + "value": "id", "type": "string" }, "type": "primitive" }, "name": { "value": { - "value": "string", + "value": "name", "type": "string" }, "type": "primitive" }, "display_name": { "value": { - "value": "string", + "value": "display_name", "type": "string" }, "type": "primitive" @@ -204,44 +204,7 @@ }, "ConnectionResponse": { "value": { - "commonProperties": [ - { - "key": "id", - "schema": { - "description": "The connection's identifier.", - "schema": { - "type": "string" - }, - "generatedName": "ConnectionCommonId", - "groupName": [], - "type": "primitive" - } - }, - { - "key": "name", - "schema": { - "description": "The connection's display name.", - "schema": { - "type": "string" - }, - "generatedName": "ConnectionCommonName", - "groupName": [], - "type": "primitive" - } - }, - { - "key": "display_name", - "schema": { - "description": "Connection name shown in the login screen.", - "schema": { - "type": "string" - }, - "generatedName": "ConnectionCommonDisplayName", - "groupName": [], - "type": "primitive" - } - } - ], + "commonProperties": [], "description": "Discriminated union of connection strategies.", "discriminantProperty": "strategy", "discriminatorContext": "data", diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/oneof-shared-allof-properties.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/oneof-shared-allof-properties.json index 67f96a9f9366..8c29c692a65e 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/oneof-shared-allof-properties.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/oneof-shared-allof-properties.json @@ -19,9 +19,9 @@ }, "response": { "body": { - "display_name": "string", - "id": "string", - "name": "string", + "display_name": "display_name", + "id": "id", + "name": "name", "options_auth0": { "tenant": "tenant", }, @@ -77,20 +77,7 @@ }, "ConnectionResponse": { "availability": undefined, - "base-properties": { - "display_name": { - "docs": "Connection name shown in the login screen.", - "type": "string", - }, - "id": { - "docs": "The connection's identifier.", - "type": "string", - }, - "name": { - "docs": "The connection's display name.", - "type": "string", - }, - }, + "base-properties": {}, "default-variant": undefined, "discriminant": { "context": "data", @@ -200,9 +187,9 @@ id: id response: body: - id: string - name: string - display_name: string + id: id + name: name + display_name: display_name options_auth0: tenant: tenant strategy: auth0 @@ -227,16 +214,7 @@ types: discriminant: value: strategy context: data - base-properties: - id: - type: string - docs: The connection's identifier. - name: - type: string - docs: The connection's display name. - display_name: - type: string - docs: Connection name shown in the login screen. + base-properties: {} docs: Discriminated union of connection strategies. union: auth0: ConnectionResponseAuth0 diff --git a/packages/cli/cli/changes/unreleased/fix-infer-discriminated-union-base-properties-overlap.yml b/packages/cli/cli/changes/unreleased/fix-infer-discriminated-union-base-properties-overlap.yml new file mode 100644 index 000000000000..04e7c05fe8d1 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-infer-discriminated-union-base-properties-overlap.yml @@ -0,0 +1,10 @@ +- summary: | + Fix OpenAPI parser emitting redundant or wrongly-required base properties for + discriminated unions when `infer-discriminated-union-base-properties: true`. + The inference now (1) skips properties every variant already inherits via a + shared `allOf $ref` parent — the parent schema is the single source of truth — + and (2) lifts an inferred property as `optional` if any variant declares it as + not-required. Together these prevent generated TypeScript SDKs from synthesizing + a `_Base` interface that collides with the real parent on either presence or + optionality (`TS2320`). + type: fix From 89c3c569f3721e5d53f8110a26aec0824ebe3611 Mon Sep 17 00:00:00 2001 From: jsklan <100491078+jsklan@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:49:44 -0400 Subject: [PATCH 02/14] chore(cli-generator): rename Docker image from fernapi/fern-cli to fernapi/fern-cli-generator (#16188) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- generators/cli/package.json | 4 ++-- generators/cli/sdk/docs/DESIGN.md | 2 +- packages/cli/cli-v2/src/sdk/config/converter/constants.ts | 2 +- packages/cli/cli/changes/5.25.0/add-cli-generator.yml | 2 +- packages/cli/cli/changes/5.37.0/cli-generator-support.yml | 2 +- .../cli/changes/unreleased/rename-cli-generator-image.yml | 6 ++++++ packages/cli/cli/versions.yml | 4 ++-- .../src/generators-yml/GeneratorName.ts | 2 +- .../cli/generation/ir-migrations/src/generatorVersionMap.ts | 2 +- .../src/__test__/LocalTaskHandler.snippetCopy.test.ts | 2 +- .../local-workspace-runner/src/__test__/rawSpecs.test.ts | 2 +- .../local-workspace-runner/src/constants.ts | 2 +- seed/cli/seed.yml | 6 +++--- .../fern/apis/cli-multi-spec-namespaced/generators.yml | 2 +- test-definitions/fern/apis/cli-multi-spec/generators.yml | 2 +- 15 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 packages/cli/cli/changes/unreleased/rename-cli-generator-image.yml diff --git a/generators/cli/package.json b/generators/cli/package.json index 443fdf59b6d5..95320c14fc36 100644 --- a/generators/cli/package.json +++ b/generators/cli/package.json @@ -27,8 +27,8 @@ "compile": "tsc", "compile:debug": "tsc --sourceMap", "dist:cli": "node build.mjs", - "dockerTagLatest": "docker build -f ./Dockerfile -t fernapi/fern-cli:latest .", - "dockerTagVersion": "turbo run dist:cli --filter . && docker build -f ./Dockerfile -t fernapi/fern-cli:${0} .", + "dockerTagLatest": "docker build -f ./Dockerfile -t fernapi/fern-cli-generator:latest .", + "dockerTagVersion": "turbo run dist:cli --filter . && docker build -f ./Dockerfile -t fernapi/fern-cli-generator:${0} .", "test": "vitest --passWithNoTests --run" }, "devDependencies": { diff --git a/generators/cli/sdk/docs/DESIGN.md b/generators/cli/sdk/docs/DESIGN.md index 4230c2525a3b..6ded9822f8d2 100644 --- a/generators/cli/sdk/docs/DESIGN.md +++ b/generators/cli/sdk/docs/DESIGN.md @@ -429,7 +429,7 @@ The current prototype embeds a single spec at compile time. The full vision (out groups: cli: generators: - - name: fernapi/fern-cli + - name: fernapi/fern-cli-generator version: 0.1.0 config: cli-name: acme diff --git a/packages/cli/cli-v2/src/sdk/config/converter/constants.ts b/packages/cli/cli-v2/src/sdk/config/converter/constants.ts index acff55234819..3264902b506c 100644 --- a/packages/cli/cli-v2/src/sdk/config/converter/constants.ts +++ b/packages/cli/cli-v2/src/sdk/config/converter/constants.ts @@ -66,7 +66,7 @@ export const DOCKER_IMAGE_TO_GENERATOR_ID: Record = { "fernapi/fern-swift-model": "swift-model", "fernapi/fern-postman": "postman", "fernapi/fern-openapi": "openapi", - "fernapi/fern-cli": "cli" + "fernapi/fern-cli-generator": "cli" }; /** diff --git a/packages/cli/cli/changes/5.25.0/add-cli-generator.yml b/packages/cli/cli/changes/5.25.0/add-cli-generator.yml index 7877edfd98d6..452ac55d60d3 100644 --- a/packages/cli/cli/changes/5.25.0/add-cli-generator.yml +++ b/packages/cli/cli/changes/5.25.0/add-cli-generator.yml @@ -1,5 +1,5 @@ - summary: | - Register the new `fernapi/fern-cli` generator in the CLI configuration. + Register the new `fernapi/fern-cli-generator` generator in the CLI configuration. type: feat - summary: | Pass raw API spec files (OAS, overrides, overlays, protobuf, etc.) to generators via Docker mount. diff --git a/packages/cli/cli/changes/5.37.0/cli-generator-support.yml b/packages/cli/cli/changes/5.37.0/cli-generator-support.yml index b84907e10897..e3c0f4352959 100644 --- a/packages/cli/cli/changes/5.37.0/cli-generator-support.yml +++ b/packages/cli/cli/changes/5.37.0/cli-generator-support.yml @@ -7,7 +7,7 @@ and `GraphQLSpec.namespace`). Enables generators that opt into `generatorWantsSpecs` to route multi-spec workspaces by their workspace-declared namespace instead of inferring one from the - runner-assigned filename. The new `fernapi/fern-cli` generator + runner-assigned filename. The new `fernapi/fern-cli-generator` generator uses this to emit `.spec_under("", ...)` in the generated `main.rs` when the user namespaces their specs. type: feat diff --git a/packages/cli/cli/changes/unreleased/rename-cli-generator-image.yml b/packages/cli/cli/changes/unreleased/rename-cli-generator-image.yml new file mode 100644 index 000000000000..f247a89b2bfd --- /dev/null +++ b/packages/cli/cli/changes/unreleased/rename-cli-generator-image.yml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Rename the CLI generator Docker image from `fernapi/fern-cli` to + `fernapi/fern-cli-generator`. + type: chore diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index d89d894e907e..0d4f7e2b2ca4 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -251,7 +251,7 @@ and `GraphQLSpec.namespace`). Enables generators that opt into `generatorWantsSpecs` to route multi-spec workspaces by their workspace-declared namespace instead of inferring one from the - runner-assigned filename. The new `fernapi/fern-cli` generator + runner-assigned filename. The new `fernapi/fern-cli-generator` generator uses this to emit `.spec_under("", ...)` in the generated `main.rs` when the user namespaces their specs. type: feat @@ -715,7 +715,7 @@ - version: 5.25.0 changelogEntry: - summary: | - Register the new `fernapi/fern-cli` generator in the CLI configuration. + Register the new `fernapi/fern-cli-generator` generator in the CLI configuration. type: feat createdAt: "2026-05-14" irVersion: 66 diff --git a/packages/cli/configuration-loader/src/generators-yml/GeneratorName.ts b/packages/cli/configuration-loader/src/generators-yml/GeneratorName.ts index 39918af8369f..569275dd173c 100644 --- a/packages/cli/configuration-loader/src/generators-yml/GeneratorName.ts +++ b/packages/cli/configuration-loader/src/generators-yml/GeneratorName.ts @@ -26,7 +26,7 @@ export const GeneratorName = { STOPLIGHT: "fernapi/fern-stoplight", POSTMAN: "fernapi/fern-postman", OPENAPI_PYTHON_CLIENT: "fernapi/openapi-python-client", - CLI: "fernapi/fern-cli" + CLI: "fernapi/fern-cli-generator" } as const; export type GeneratorName = (typeof GeneratorName)[keyof typeof GeneratorName]; diff --git a/packages/cli/generation/ir-migrations/src/generatorVersionMap.ts b/packages/cli/generation/ir-migrations/src/generatorVersionMap.ts index 14bf4dc2f0c8..f6e010e1b081 100644 --- a/packages/cli/generation/ir-migrations/src/generatorVersionMap.ts +++ b/packages/cli/generation/ir-migrations/src/generatorVersionMap.ts @@ -5,7 +5,7 @@ import { MINIMUM_SUPPORTED_IR_VERSION } from "./constants.js"; export const GENERATOR_MINIMUM_VERSIONS: Record = { - "fernapi/fern-cli": "0.0.1", + "fernapi/fern-cli-generator": "0.0.1", "fernapi/fern-csharp-model": "0.0.2", "fernapi/fern-csharp-sdk": "0.5.0", "fernapi/fern-express": "0.17.3", diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/LocalTaskHandler.snippetCopy.test.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/LocalTaskHandler.snippetCopy.test.ts index 754eecc74c6c..2627ecc167c2 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/LocalTaskHandler.snippetCopy.test.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/LocalTaskHandler.snippetCopy.test.ts @@ -7,7 +7,7 @@ import { copySnippetJsonIfNonEmpty } from "../LocalTaskHandler.js"; /** * Unit coverage for the empty-stub skip behavior introduced when the - * `fernapi/fern-cli` generator started shipping outputs without + * `fernapi/fern-cli-generator` generator started shipping outputs without * per-endpoint snippets. `runGenerator` pre-creates an empty * `snippet.json` in the workspace tmp dir; if we blindly copy it into * the user's output every generator without a snippet emission leaves a diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts index a227121f90b9..9d0536db890b 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts @@ -882,7 +882,7 @@ describe("filterSpec", () => { /** * Coverage for the per-entry `namespace` field added to * `RawSpecsManifestEntry`. Downstream generators (initially - * `fernapi/fern-cli`) rely on this field to route multi-spec + * `fernapi/fern-cli-generator`) rely on this field to route multi-spec * workspaces by their `generators.yml`-declared namespace rather than * inferring one from the runner-assigned filename. */ diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts index dedf5848cb87..db199336028b 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts @@ -37,7 +37,7 @@ export const DEFAULT_NODE_DEBUG_PORT = "9229"; * Generators that receive pre-processed raw API spec files mounted into their * Docker container. Add new generator names here as they opt in. */ -const GENERATORS_WANTING_SPECS: ReadonlySet = new Set(["fernapi/fern-cli"]); +const GENERATORS_WANTING_SPECS: ReadonlySet = new Set(["fernapi/fern-cli-generator"]); export function generatorWantsSpecs(generatorName: string): boolean { return GENERATORS_WANTING_SPECS.has(generatorName); diff --git a/seed/cli/seed.yml b/seed/cli/seed.yml index 85acbd5f76ee..5df39b73eb63 100644 --- a/seed/cli/seed.yml +++ b/seed/cli/seed.yml @@ -1,6 +1,6 @@ irVersion: v67 displayName: CLI -image: fernapi/fern-cli +image: fernapi/fern-cli-generator changelogLocation: ../../generators/cli/versions.yml buildScripts: @@ -16,12 +16,12 @@ publish: - pnpm turbo run dist:cli --filter @fern-api/cli-generator docker: file: ./generators/cli/Dockerfile - image: fernapi/fern-cli + image: fernapi/fern-cli-generator context: ./generators/cli test: docker: - image: fernapi/fern-cli:latest + image: fernapi/fern-cli-generator:latest command: pnpm turbo run dockerTagLatest --filter @fern-api/cli-generator local: workingDirectory: generators/cli diff --git a/test-definitions/fern/apis/cli-multi-spec-namespaced/generators.yml b/test-definitions/fern/apis/cli-multi-spec-namespaced/generators.yml index 22e5eb38949e..40b53a6b3027 100644 --- a/test-definitions/fern/apis/cli-multi-spec-namespaced/generators.yml +++ b/test-definitions/fern/apis/cli-multi-spec-namespaced/generators.yml @@ -13,6 +13,6 @@ api: groups: cli: generators: - - name: fernapi/fern-cli + - name: fernapi/fern-cli-generator version: latest ir-version: latest diff --git a/test-definitions/fern/apis/cli-multi-spec/generators.yml b/test-definitions/fern/apis/cli-multi-spec/generators.yml index 61d39e9bfbbe..91a1404ec22d 100644 --- a/test-definitions/fern/apis/cli-multi-spec/generators.yml +++ b/test-definitions/fern/apis/cli-multi-spec/generators.yml @@ -10,6 +10,6 @@ api: groups: cli: generators: - - name: fernapi/fern-cli + - name: fernapi/fern-cli-generator version: latest ir-version: latest From 6688ff1f21c5b332fbd158dea9ca74e1b4e08d7f Mon Sep 17 00:00:00 2001 From: Fern Support <126544928+fern-support@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:50:23 -0400 Subject: [PATCH 03/14] chore(cli-generator): sync cli-sdk@9c1f38f (#16187) * chore(cli-generator): sync cli-sdk@9c1f38f * chore(cli-generator): add changelog for cli-sdk@9c1f38f sync --------- Co-authored-by: jsklan <100491078+jsklan@users.noreply.github.com> Co-authored-by: jsklan --- _cli-sdk | 1 + .../unreleased/sync-cli-sdk-9c1f38f.yml | 57 + generators/cli/sdk/.synced-from | 1 + generators/cli/sdk/Cargo.lock | 145 +- generators/cli/sdk/Cargo.toml | 3 +- .../cli/sdk/cli/openapi-fixture/openapi.yaml | 123 ++ generators/cli/sdk/src/auth/builder.rs | 58 +- generators/cli/sdk/src/auth/compose.rs | 198 +++ generators/cli/sdk/src/auth/credential.rs | 115 +- generators/cli/sdk/src/auth/error.rs | 212 ++- generators/cli/sdk/src/auth/mod.rs | 8 +- generators/cli/sdk/src/auth/oauth2.rs | 26 +- generators/cli/sdk/src/auth/provider.rs | 7 + generators/cli/sdk/src/auth/root_builder.rs | 1 - generators/cli/sdk/src/auth/schemes.rs | 22 +- generators/cli/sdk/src/graphql/app.rs | 1 + generators/cli/sdk/src/graphql/binding.rs | 110 +- generators/cli/sdk/src/graphql/commands.rs | 11 + generators/cli/sdk/src/graphql/executor.rs | 95 +- generators/cli/sdk/src/graphql/parser.rs | 17 + generators/cli/sdk/src/http.rs | 362 ++++- generators/cli/sdk/src/openapi/app.rs | 444 +++++- generators/cli/sdk/src/openapi/binding.rs | 51 +- generators/cli/sdk/src/openapi/commands.rs | 795 ++++++++++- generators/cli/sdk/src/openapi/discovery.rs | 60 +- generators/cli/sdk/src/openapi/executor.rs | 1244 +++++++++++++++-- generators/cli/sdk/src/openapi/overlay.rs | 105 ++ generators/cli/sdk/src/openapi/parser.rs | 775 +++++++++- .../cli/sdk/src/openapi/skill_emitter.rs | 3 +- generators/cli/sdk/src/text.rs | 221 +++ generators/cli/sdk/src/websocket/auth.rs | 61 +- generators/cli/sdk/src/websocket/client.rs | 222 ++- generators/cli/sdk/src/websocket/error.rs | 51 +- generators/cli/sdk/src/websocket/mod.rs | 12 +- generators/cli/sdk/tests/auth_routing_wire.rs | 728 ---------- generators/cli/sdk/tests/common/mod.rs | 260 ---- .../sdk/tests/extension_surface_behavior.rs | 840 ----------- .../fixtures/openapi-fixture-mappings.json | 116 -- generators/cli/sdk/tests/lib_api.rs | 50 - generators/cli/sdk/tests/overlay_fixture.rs | 107 -- generators/cli/sdk/tests/tls_env_vars.rs | 339 ----- 41 files changed, 5220 insertions(+), 2837 deletions(-) create mode 160000 _cli-sdk create mode 100644 generators/cli/changes/unreleased/sync-cli-sdk-9c1f38f.yml create mode 100644 generators/cli/sdk/.synced-from delete mode 100644 generators/cli/sdk/tests/auth_routing_wire.rs delete mode 100644 generators/cli/sdk/tests/common/mod.rs delete mode 100644 generators/cli/sdk/tests/extension_surface_behavior.rs delete mode 100644 generators/cli/sdk/tests/fixtures/openapi-fixture-mappings.json delete mode 100644 generators/cli/sdk/tests/lib_api.rs delete mode 100644 generators/cli/sdk/tests/overlay_fixture.rs delete mode 100644 generators/cli/sdk/tests/tls_env_vars.rs diff --git a/_cli-sdk b/_cli-sdk new file mode 160000 index 000000000000..9c1f38fd0ffb --- /dev/null +++ b/_cli-sdk @@ -0,0 +1 @@ +Subproject commit 9c1f38fd0ffba8da21db241c07179f48fa0cecf7 diff --git a/generators/cli/changes/unreleased/sync-cli-sdk-9c1f38f.yml b/generators/cli/changes/unreleased/sync-cli-sdk-9c1f38f.yml new file mode 100644 index 000000000000..3fab1f06123f --- /dev/null +++ b/generators/cli/changes/unreleased/sync-cli-sdk-9c1f38f.yml @@ -0,0 +1,57 @@ +# yaml-language-server: $schema=../../../../fern-changes-yml.schema.json +# Entries below describe the user-facing changes pulled into generated CLIs by +# syncing the vendored fern-cli-sdk to cli-sdk@9c1f38f. Each maps to an upstream +# fern-api/cli-sdk PR. + +- summary: | + Generated CLIs now serialize OpenAPI query parameters according to their + declared `style`/`explode` (form, spaceDelimited, pipeDelimited, deepObject). + (cli-sdk #144, FER-10569) + type: feat + +- summary: | + Generated CLIs now serialize OpenAPI header parameters using the `simple` + style. (cli-sdk #137, FER-10569) + type: feat + +- summary: | + Generated CLIs now serialize OpenAPI path parameters using the `simple`, + `label`, and `matrix` styles. (cli-sdk #138, FER-10569) + type: feat + +- summary: | + Generated CLIs support `multipart/form-data` request bodies, exposing each + part as its own flag. (cli-sdk #88, FER-10569) + type: feat + +- summary: | + `multipart/form-data` request bodies accept `--file` parts, so file-upload + endpoints can stream a file from disk. (cli-sdk #145, FER-10569) + type: feat + +- summary: | + HTTP requests now retry on transient failures with exponential backoff and + automatically attach an `Idempotency-Key` header to retried mutations. + (cli-sdk #86, FER-10521) + type: feat + +- summary: | + OpenAPI parameter names are sanitized into valid, readable CLI flags instead + of being passed through verbatim. (cli-sdk #87, FER-10430) + type: feat + +- summary: | + Auth error messages now name the specific environment variable the CLI + expected, making missing-credential failures easier to diagnose. + (cli-sdk #81, FER-10702) + type: feat + +- summary: | + Auth schemes support multiple headers, enabling APIs that require more than + one credential header (e.g. Sandboxes). (cli-sdk #116) + type: feat + +- summary: | + Nullable scalar request-body flags emit a null sentinel, letting users + distinguish an explicit `null` from an omitted value. (cli-sdk 8234544) + type: feat diff --git a/generators/cli/sdk/.synced-from b/generators/cli/sdk/.synced-from new file mode 100644 index 000000000000..72c8253e8746 --- /dev/null +++ b/generators/cli/sdk/.synced-from @@ -0,0 +1 @@ +cli-sdk@9c1f38fd0ffba8da21db241c07179f48fa0cecf7 diff --git a/generators/cli/sdk/Cargo.lock b/generators/cli/sdk/Cargo.lock index a5a694a2abd8..0f74437d387c 100644 --- a/generators/cli/sdk/Cargo.lock +++ b/generators/cli/sdk/Cargo.lock @@ -91,9 +91,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "block-buffer" @@ -106,9 +106,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -124,9 +124,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -305,9 +305,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -376,6 +376,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "unicode-normalization", "wiremock", ] @@ -616,9 +617,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -661,9 +662,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -886,9 +887,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -937,9 +938,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" [[package]] name = "lru-slab" @@ -958,9 +959,25 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] [[package]] name = "minimal-lexical" @@ -970,9 +987,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1362,6 +1379,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -1442,9 +1460,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -1485,15 +1503,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.29" @@ -1509,12 +1518,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "secrecy" version = "0.10.3" @@ -1685,24 +1688,23 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", @@ -1742,9 +1744,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -1770,9 +1772,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2191,9 +2193,15 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" @@ -2201,6 +2209,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2302,9 +2319,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -2315,9 +2332,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -2325,9 +2342,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2335,9 +2352,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -2348,9 +2365,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -2404,9 +2421,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -2732,18 +2749,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/generators/cli/sdk/Cargo.toml b/generators/cli/sdk/Cargo.toml index 988350203b7d..2a63dc0e8ed7 100644 --- a/generators/cli/sdk/Cargo.toml +++ b/generators/cli/sdk/Cargo.toml @@ -66,7 +66,7 @@ futures-util = "0.3" httpdate = "1" libc = "0.2" percent-encoding = "2.3.2" -reqwest = { version = "0.12", features = ["json", "stream"], default-features = false } +reqwest = { version = "0.12", features = ["json", "multipart", "stream"], default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_json_path = "0.7" @@ -81,6 +81,7 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +unicode-normalization = "0.1.25" form_urlencoded = "1" [package.metadata.dist] diff --git a/generators/cli/sdk/cli/openapi-fixture/openapi.yaml b/generators/cli/sdk/cli/openapi-fixture/openapi.yaml index 3be52fa29921..5cdae682d3d0 100644 --- a/generators/cli/sdk/cli/openapi-fixture/openapi.yaml +++ b/generators/cli/sdk/cli/openapi-fixture/openapi.yaml @@ -132,6 +132,15 @@ paths: external: name: External description: External collaborators only. + # `status:in` exercises FER-10430 flag-name sanitization. The + # colon is a shell-special character (YAML separator, completion + # quirks) so the CLI must expose `--status-in` while the wire + # request still sends `status:in=` in the query string. + - name: "status:in" + in: query + description: Filter users by status. Comma-separated list. + schema: + type: string # `limit` exercises the standard OpenAPI `default:` keyword. The # extension is absent, so the schema default is treated as a # documentation hint — it shows up in `--help` but the CLI does @@ -969,6 +978,120 @@ paths: "201": description: Created order + # --- uploads (multipart/form-data) --- + # Endpoint with a multipart/form-data body containing both text and + # file fields. Exercises the multipart parser + executor pipeline. + /uploads: + post: + x-fern-sdk-group-name: + - uploads + x-fern-sdk-method-name: create + operationId: uploads_create + summary: Upload a file with metadata + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary + description: The file to upload + purpose: + type: string + description: Purpose of the upload + responses: + "201": + description: Upload created + content: + application/json: + schema: + type: object + properties: + id: + type: string + filename: + type: string + purpose: + type: string + + # Endpoint with multipart/form-data where all fields are text + # (no file upload). Covers the text-only multipart case. + /uploads/metadata: + post: + x-fern-sdk-group-name: + - uploads + x-fern-sdk-method-name: create-metadata + operationId: uploads_createMetadata + summary: Create upload metadata (text-only multipart) + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - name + properties: + name: + type: string + description: Name of the upload + tags: + type: string + description: Comma-separated tags + responses: + "201": + description: Metadata created + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + + # Endpoint whose multipart field name collides with the builtin --output + # flag. Exercises the guard in collect_multipart_parts that prevents the + # builtin flag value from leaking into the request body. + /uploads/collide: + post: + x-fern-sdk-group-name: + - uploads + x-fern-sdk-method-name: create-collide + operationId: uploads_createCollide + summary: Multipart with a field named output (builtin collision) + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + output: + type: string + description: Field that collides with the builtin --output flag + description: + type: string + description: A normal text field + responses: + "201": + description: Created + content: + application/json: + schema: + type: object + properties: + id: + type: string + output: + type: string + # --- messages (form-urlencoded) --- /messages: diff --git a/generators/cli/sdk/src/auth/builder.rs b/generators/cli/sdk/src/auth/builder.rs index e629dd01553d..4469a75acd3e 100644 --- a/generators/cli/sdk/src/auth/builder.rs +++ b/generators/cli/sdk/src/auth/builder.rs @@ -135,6 +135,36 @@ pub fn render_auth_help_section(bindings: &[(String, SchemeBinding)]) -> Option< Some(out) } +/// Render the optional-layer rows for the `--help` "Authentication:" block. +/// +/// Layers are additive supplementary headers (e.g. a sandbox-only token) that +/// are attached on top of the primary auth when their credential is present. +/// Each row is rendered as ` (optional)` and the block is +/// returned *without* the `Authentication:` heading so the caller can append +/// it under [`render_auth_help_section`]'s output (or supply the heading +/// itself when there are no primary bindings). +/// +/// Returns `None` when there are no layers. +pub fn render_auth_layers_help(layers: &[(String, Vec)]) -> Option { + if layers.is_empty() { + return None; + } + let max_name = layers.iter().map(|(n, _)| n.len()).max().unwrap_or(0).max(8); + let mut out = String::new(); + for (name, hints) in layers { + let sources = if hints.is_empty() { + "custom (optional)".to_string() + } else { + format!("{} (optional)", hints.join(" / ")) + }; + let _ = std::fmt::Write::write_fmt( + &mut out, + format_args!(" {name: String { match binding { SchemeBinding::Token(src) => describe_credential_source(src), @@ -155,7 +185,8 @@ fn describe_credential_source(src: &AuthCredentialSource) -> String { AuthCredentialSource::Cli(arg) => format!("--{arg} flag"), AuthCredentialSource::File(path) => format!("{} file", path.display()), AuthCredentialSource::Literal(_) => "built-in literal".to_string(), - AuthCredentialSource::Closure(_) => "custom resolver".to_string(), + AuthCredentialSource::Closure(_, Some(hint)) => hint.clone(), + AuthCredentialSource::Closure(_, None) => "custom resolver".to_string(), AuthCredentialSource::Chain(sources) => sources .iter() .map(describe_credential_source) @@ -833,6 +864,30 @@ mod tests { assert!(out.contains("API_PASS env var")); } + #[test] + fn render_auth_layers_help_none_for_empty() { + assert!(render_auth_layers_help(&[]).is_none()); + } + + #[test] + fn render_auth_layers_help_marks_optional_with_hints() { + let layers = vec![( + "sandboxAuthorization".to_string(), + vec!["SANDBOXES_TOKEN environment variable".to_string()], + )]; + let out = render_auth_layers_help(&layers).unwrap(); + assert!(out.contains("sandboxAuthorization")); + assert!(out.contains("SANDBOXES_TOKEN environment variable")); + assert!(out.contains("(optional)")); + } + + #[test] + fn render_auth_layers_help_handles_hintless_layer() { + let layers = vec![("x".to_string(), Vec::new())]; + let out = render_auth_layers_help(&layers).unwrap(); + assert!(out.contains("custom (optional)")); + } + #[test] fn render_auth_help_section_marks_custom_provider_opaque() { let bindings = vec![( @@ -857,5 +912,4 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } - } diff --git a/generators/cli/sdk/src/auth/compose.rs b/generators/cli/sdk/src/auth/compose.rs index dd06d2365493..35adaf07904e 100644 --- a/generators/cli/sdk/src/auth/compose.rs +++ b/generators/cli/sdk/src/auth/compose.rs @@ -41,6 +41,13 @@ impl AuthProvider for AnyAuthProvider { self.providers.iter().any(|p| p.has_credentials()) } + fn credential_hints(&self) -> Vec { + self.providers + .iter() + .flat_map(|p| p.credential_hints()) + .collect() + } + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { self.providers .iter() @@ -105,6 +112,13 @@ impl AuthProvider for AllAuthProvider { !self.providers.is_empty() && self.providers.iter().all(|p| p.has_credentials()) } + fn credential_hints(&self) -> Vec { + self.providers + .iter() + .flat_map(|p| p.credential_hints()) + .collect() + } + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { !self.providers.is_empty() && self @@ -136,6 +150,83 @@ impl AuthProvider for AllAuthProvider { } } +// --------------------------------------------------------------------------- +// LayeredAuthProvider — a primary scheme plus optional additive headers. +// --------------------------------------------------------------------------- + +/// Wrap a `primary` provider and layer zero or more *optional* providers on +/// top of it. The layers are additive supplements, not alternatives: every +/// layer that currently has credentials is applied to the request in addition +/// to whatever the primary attaches. +/// +/// Unlike [`AllAuthProvider`], a layer never makes the request mandatory — +/// satisfiability (`has_credentials` / `has_credentials_for`) is decided by +/// the primary alone, and a layer with no credentials is simply skipped. This +/// is the right shape for a supplementary header that sits *alongside* real +/// auth and is only present in some environments. +/// +/// The motivating case is Lattice Sandboxes: every request carries the normal +/// `Authorization: Bearer ` (the primary) and, only when developing +/// against a sandbox, an additional `Anduril-Sandbox-Authorization: Bearer +/// ` header (a layer). In production the layer's credential +/// is absent, so the request behaves exactly as if no layer were configured. +#[derive(Debug, Clone)] +pub struct LayeredAuthProvider { + primary: DynAuthProvider, + layers: Vec, +} + +impl LayeredAuthProvider { + pub fn new(primary: DynAuthProvider, layers: Vec) -> Self { + Self { primary, layers } + } +} + +impl AuthProvider for LayeredAuthProvider { + fn name(&self) -> &str { + self.primary.name() + } + + fn has_credentials(&self) -> bool { + // Optional layers don't gate satisfiability — the primary decides + // whether the CLI can authenticate at all. + self.primary.has_credentials() + } + + fn credential_hints(&self) -> Vec { + // Surface only the primary's hints in the friendly auth-error path: + // a missing optional layer (e.g. no sandbox token in production) is + // not a misconfiguration and shouldn't be reported as a missing + // credential. + self.primary.credential_hints() + } + + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + self.primary.has_credentials_for(endpoint) + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + // Explicitly anonymous endpoints (`security: []`) opt out of auth + // entirely. The executor already short-circuits these before calling + // `apply`, but guard here too so a supplementary header can never + // leak onto an opt-out endpoint regardless of the call path. + if endpoint.is_explicit_anonymous() { + return Ok(request); + } + let mut req = self.primary.apply(request, endpoint)?; + for layer in &self.layers { + if layer.has_credentials() { + req = layer.apply(req, endpoint)?; + } + } + Ok(req) + } +} + // --------------------------------------------------------------------------- // RoutingAuthProvider — per-endpoint security map. // --------------------------------------------------------------------------- @@ -189,6 +280,18 @@ impl AuthProvider for RoutingAuthProvider { || self.default.as_ref().is_some_and(|p| p.has_credentials()) } + fn credential_hints(&self) -> Vec { + let mut hints: Vec = self + .schemes + .values() + .flat_map(|p| p.credential_hints()) + .collect(); + if let Some(d) = &self.default { + hints.extend(d.credential_hints()); + } + hints + } + /// Endpoint-aware credential check. /// /// - **No requirements declared**: defer to the wrapper's `default` @@ -393,6 +496,101 @@ mod tests { assert!(matches!(err, CliError::Auth(_))); } + // -------- LayeredAuthProvider -------- + + #[tokio::test] + async fn layered_applies_primary_and_present_layer() { + let primary: DynAuthProvider = bearer("bearer", "tok"); + let layer: DynAuthProvider = Arc::new(HeaderAuthProvider::new( + "sandbox", + "Anduril-Sandbox-Authorization", + AuthCredentialSource::literal("sbx"), + true, + )); + let p = LayeredAuthProvider::new(primary, vec![layer]); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built + .headers() + .get("anduril-sandbox-authorization") + .and_then(|v| v.to_str().ok()), + Some("Bearer sbx"), + ); + } + + #[tokio::test] + async fn layered_skips_layer_without_credentials() { + // Production case: no sandbox token set. The layer is silently + // skipped and the request behaves as if it weren't configured. + let primary: DynAuthProvider = bearer("bearer", "tok"); + let layer: DynAuthProvider = Arc::new(HeaderAuthProvider::new( + "sandbox", + "Anduril-Sandbox-Authorization", + AuthCredentialSource::Missing, + true, + )); + let p = LayeredAuthProvider::new(primary, vec![layer]); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert!(built.headers().get("anduril-sandbox-authorization").is_none()); + } + + #[test] + fn layered_satisfiability_follows_primary_only() { + // A present layer must not make an otherwise-unauthenticated CLI + // claim it has credentials. + let primary: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )); + let layer: DynAuthProvider = api_key("sandbox", "Anduril-Sandbox-Authorization", "sbx"); + let p = LayeredAuthProvider::new(primary, vec![layer]); + assert!(!p.has_credentials()); + assert_eq!(p.name(), "bearer"); + } + + #[test] + fn layered_credential_hints_exclude_layers() { + // The friendly auth-error path should point at the primary's source, + // not nag about an optional layer that's missing by design. + let primary: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::from_env("ENVIRONMENT_TOKEN"), + )); + let layer: DynAuthProvider = Arc::new(HeaderAuthProvider::new( + "sandbox", + "Anduril-Sandbox-Authorization", + AuthCredentialSource::from_env("SANDBOXES_TOKEN"), + true, + )); + let p = LayeredAuthProvider::new(primary, vec![layer]); + let hints = p.credential_hints(); + assert_eq!(hints, vec!["ENVIRONMENT_TOKEN environment variable"]); + } + + #[tokio::test] + async fn layered_does_not_attach_to_explicit_anonymous_endpoint() { + // `security: []` opts the operation out of auth entirely — neither + // the primary nor the supplementary layer should be attached. + let primary: DynAuthProvider = bearer("bearer", "tok"); + let layer: DynAuthProvider = api_key("sandbox", "Anduril-Sandbox-Authorization", "sbx"); + let p = LayeredAuthProvider::new(primary, vec![layer]); + let r = p + .apply(req(), &EndpointAuthMetadata::explicit_anonymous()) + .unwrap(); + let built = r.build().unwrap(); + assert!(built.headers().get("anduril-sandbox-authorization").is_none()); + } + // -------- RoutingAuthProvider -------- fn routing_setup() -> RoutingAuthProvider { diff --git a/generators/cli/sdk/src/auth/credential.rs b/generators/cli/sdk/src/auth/credential.rs index 68734684e069..6b4c7faff370 100644 --- a/generators/cli/sdk/src/auth/credential.rs +++ b/generators/cli/sdk/src/auth/credential.rs @@ -57,7 +57,12 @@ pub enum AuthCredentialSource { /// A user-supplied closure invoked on every request. The escape hatch /// for any source the built-in variants don't cover (token refresh, /// shell-out, OS keychain, etc.). - Closure(CredentialClosure), + /// + /// The optional `String` carries a human-readable credential hint + /// (e.g. `"--api-token flag"`) so that `credential_hints()` can still + /// report the original source after `finalize()` replaces `Cli` with + /// a `Closure`. + Closure(CredentialClosure, Option), /// No source bound. The provider will report itself as unable to /// satisfy requests. Missing, @@ -94,7 +99,7 @@ impl AuthCredentialSource { where F: Fn() -> Option + Send + Sync + 'static, { - AuthCredentialSource::Closure(Arc::new(f)) + AuthCredentialSource::Closure(Arc::new(f), None) } /// Resolve the value, if available. Empty strings are treated as @@ -117,11 +122,29 @@ impl AuthCredentialSource { AuthCredentialSource::Literal(v) if v.is_empty() => None, AuthCredentialSource::Literal(v) => Some(SecretString::from(v.clone())), AuthCredentialSource::Chain(sources) => sources.iter().find_map(|s| s.resolve()), - AuthCredentialSource::Closure(f) => f().filter(|v| !v.is_empty()).map(SecretString::from), + AuthCredentialSource::Closure(f, _) => f().filter(|v| !v.is_empty()).map(SecretString::from), AuthCredentialSource::Missing => None, } } + /// Human-readable descriptions of where this source reads credentials + /// from. Used by the auth-error path to tell the user which env var, + /// flag, or file to set. + pub fn credential_hints(&self) -> Vec { + match self { + AuthCredentialSource::Env(name) => vec![format!("{name} environment variable")], + AuthCredentialSource::Cli(arg) => vec![format!("--{arg} flag")], + AuthCredentialSource::File(path) => vec![format!("{} file", path.display())], + AuthCredentialSource::Chain(sources) => { + sources.iter().flat_map(|s| s.credential_hints()).collect() + } + AuthCredentialSource::Closure(_, Some(hint)) => vec![hint.clone()], + AuthCredentialSource::Literal(_) + | AuthCredentialSource::Closure(_, None) + | AuthCredentialSource::Missing => Vec::new(), + } + } + /// Recursively collect every CLI arg name this source references. /// CliApp uses this before clap parsing to register the corresponding /// global `--` flags. @@ -145,7 +168,7 @@ impl AuthCredentialSource { AuthCredentialSource::Env(_) | AuthCredentialSource::File(_) | AuthCredentialSource::Literal(_) - | AuthCredentialSource::Closure(_) + | AuthCredentialSource::Closure(_, _) | AuthCredentialSource::Missing => {} } } @@ -160,9 +183,13 @@ impl AuthCredentialSource { match self { AuthCredentialSource::Cli(name) => { let m = Arc::clone(matches); - AuthCredentialSource::Closure(Arc::new(move || { - m.try_get_one::(&name).ok().flatten().cloned() - })) + let hint = format!("--{name} flag"); + AuthCredentialSource::Closure( + Arc::new(move || { + m.try_get_one::(&name).ok().flatten().cloned() + }), + Some(hint), + ) } AuthCredentialSource::Chain(sources) => { AuthCredentialSource::Chain( @@ -182,7 +209,13 @@ impl std::fmt::Debug for AuthCredentialSource { AuthCredentialSource::File(path) => write!(f, "File({})", path.display()), AuthCredentialSource::Literal(_) => write!(f, "Literal()"), AuthCredentialSource::Chain(sources) => f.debug_tuple("Chain").field(sources).finish(), - AuthCredentialSource::Closure(_) => write!(f, "Closure"), + AuthCredentialSource::Closure(_, hint) => { + if let Some(h) = hint { + write!(f, "Closure({h})") + } else { + write!(f, "Closure") + } + } AuthCredentialSource::Missing => write!(f, "Missing"), } } @@ -546,4 +579,70 @@ mod tests { assert!(!dbg.contains("super-secret")); assert!(dbg.contains("redacted")); } + + // -------- credential_hints -------- + + #[test] + fn credential_hints_env() { + let s = AuthCredentialSource::from_env("MY_TOKEN"); + assert_eq!(s.credential_hints(), vec!["MY_TOKEN environment variable"]); + } + + #[test] + fn credential_hints_cli() { + let s = AuthCredentialSource::cli("api-token"); + assert_eq!(s.credential_hints(), vec!["--api-token flag"]); + } + + #[test] + fn credential_hints_file() { + let s = AuthCredentialSource::file("~/.config/token"); + assert_eq!( + s.credential_hints(), + vec!["~/.config/token file"], + ); + } + + #[test] + fn credential_hints_chain_collects_all() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("API_TOKEN"), + AuthCredentialSource::file("~/.token"), + ]); + assert_eq!( + s.credential_hints(), + vec![ + "--api-token flag", + "API_TOKEN environment variable", + "~/.token file", + ], + ); + } + + #[test] + fn credential_hints_missing_is_empty() { + assert!(AuthCredentialSource::Missing.credential_hints().is_empty()); + } + + #[test] + fn credential_hints_literal_is_empty() { + assert!(AuthCredentialSource::literal("x").credential_hints().is_empty()); + } + + #[test] + fn credential_hints_closure_without_hint_is_empty() { + let s = AuthCredentialSource::closure(|| Some("x".into())); + assert!(s.credential_hints().is_empty()); + } + + #[test] + fn credential_hints_closure_with_hint_from_finalize() { + let cmd = clap::Command::new("test").arg( + clap::Arg::new("api-token").long("api-token").num_args(1), + ); + let matches = Arc::new(cmd.try_get_matches_from(vec!["test"]).unwrap()); + let s = AuthCredentialSource::cli("api-token").finalize(&matches); + assert_eq!(s.credential_hints(), vec!["--api-token flag"]); + } } diff --git a/generators/cli/sdk/src/auth/error.rs b/generators/cli/sdk/src/auth/error.rs index 0c34ba048c90..49c39a32f9d7 100644 --- a/generators/cli/sdk/src/auth/error.rs +++ b/generators/cli/sdk/src/auth/error.rs @@ -37,16 +37,33 @@ pub fn handle_error_response( if (status.as_u16() == 401 || status.as_u16() == 403) && !provider.has_credentials_for(endpoint) { - return Err(CliError::Auth( - "Access denied. This request was sent without authentication \ - credentials. Check that the configured auth source for this CLI \ + let hints = provider.credential_hints(); + let message = if hints.is_empty() { + "Access denied. Authentication credentials are missing. \ + Check that the configured auth source for this CLI \ (environment variable, --flag, or credential file) has a value set." - .to_string(), - )); + .to_string() + } else { + let joined = dedup_preserve_order(hints).join(", "); + format!( + "Access denied. Authentication credentials are missing. \ + Set {joined}.", + ) + }; + return Err(CliError::Auth(message)); } Err(parse_api_error(status, error_body)) } +/// Deduplicate strings while preserving first-seen order. +fn dedup_preserve_order(items: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + items + .into_iter() + .filter(|s| seen.insert(s.clone())) + .collect() +} + /// Shared parsing for the auth-aware error handler. Returns a structured /// [`CliError::Api`] whether or not the body was JSON. fn parse_api_error(status: reqwest::StatusCode, error_body: &str) -> CliError { @@ -187,4 +204,189 @@ mod tests { _ => panic!("Expected Api"), } } + + #[test] + fn friendly_error_names_env_var_bearer() { + let p = BearerAuthProvider::new( + "bearerAuth", + AuthCredentialSource::from_env("__FERN_TEST_BEARER_KEY"), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!( + msg.contains("__FERN_TEST_BEARER_KEY"), + "expected env var name in message, got: {msg}", + ); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_names_env_var_header() { + use crate::auth::schemes::HeaderAuthProvider; + let p = HeaderAuthProvider::new( + "X-Auth-Token", + "X-Auth-Token", + AuthCredentialSource::from_env("__FERN_TEST_HEADER_KEY"), + false, + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!( + msg.contains("__FERN_TEST_HEADER_KEY"), + "expected env var name in message, got: {msg}", + ); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_names_env_var_basic() { + use crate::auth::schemes::BasicAuthProvider; + let p = BasicAuthProvider::username_only( + "ApiKeyAuth", + AuthCredentialSource::from_env("__FERN_TEST_BASIC_KEY"), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!( + msg.contains("__FERN_TEST_BASIC_KEY"), + "expected env var name in message, got: {msg}", + ); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_names_cli_flag_in_chain() { + // Use a non-finalized source to verify pre-finalize hints. + let p = BearerAuthProvider::new( + "bearer", + AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("__FERN_TEST_CHAIN_TOKEN"), + ]), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!(msg.contains("--api-token"), "expected flag hint, got: {msg}"); + assert!(msg.contains("__FERN_TEST_CHAIN_TOKEN"), "expected env var hint, got: {msg}"); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_names_cli_flag_after_finalize() { + // Simulate the production path: finalize() converts Cli to Closure, + // but the hint must survive so the error message still names the flag. + let cmd = clap::Command::new("test").arg( + clap::Arg::new("api-token").long("api-token").num_args(1), + ); + let matches = std::sync::Arc::new( + cmd.try_get_matches_from(vec!["test"]).unwrap(), + ); + let source = AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("__FERN_TEST_FINALIZE_TOKEN"), + ]) + .finalize(&matches); + + let p = BearerAuthProvider::new("bearer", source); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!(msg.contains("--api-token"), "expected flag hint after finalize, got: {msg}"); + assert!(msg.contains("__FERN_TEST_FINALIZE_TOKEN"), "expected env var hint after finalize, got: {msg}"); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_fallback_when_no_hints() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::Missing); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!(msg.contains("Access denied"), "expected fallback msg, got: {msg}"); + assert!( + msg.contains("environment variable, --flag, or credential file"), + "expected generic hint in fallback, got: {msg}", + ); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_json_envelope_contains_env_var() { + let p = BearerAuthProvider::new( + "bearerAuth", + AuthCredentialSource::from_env("__FERN_TEST_JSON_KEY"), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + let json = err.to_json(); + let json_msg = json["error"]["message"].as_str().unwrap(); + assert!( + json_msg.contains("__FERN_TEST_JSON_KEY"), + "expected env var in JSON message, got: {json_msg}", + ); + } + + #[test] + fn dedup_removes_duplicates_preserving_order() { + let input = vec!["a".into(), "b".into(), "a".into(), "c".into(), "b".into()]; + let result = dedup_preserve_order(input); + assert_eq!(result, vec!["a", "b", "c"]); + } } diff --git a/generators/cli/sdk/src/auth/mod.rs b/generators/cli/sdk/src/auth/mod.rs index 6c7d7b703bb2..2254547db7d1 100644 --- a/generators/cli/sdk/src/auth/mod.rs +++ b/generators/cli/sdk/src/auth/mod.rs @@ -26,7 +26,7 @@ //! - [`schemes`] — concrete [`BearerAuthProvider`], [`BasicAuthProvider`], //! and [`HeaderAuthProvider`] implementations. //! - [`compose`] — composition wrappers: [`AnyAuthProvider`], -//! [`AllAuthProvider`], [`RoutingAuthProvider`]. +//! [`AllAuthProvider`], [`LayeredAuthProvider`], [`RoutingAuthProvider`]. //! - [`builder`] — [`SchemeBinding`], [`AuthStrategy`], and the //! `build_provider_*` factories that `CliApp` calls. //! - [`error`] — auth-aware HTTP error mapping (`handle_error_response`). @@ -47,11 +47,11 @@ pub(crate) mod test_helpers; pub use builder::{ build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - collect_binding_cli_args, finalize_bindings, render_auth_help_section, AuthStrategy, - SchemeBinding, + collect_binding_cli_args, finalize_bindings, render_auth_help_section, render_auth_layers_help, + AuthStrategy, SchemeBinding, }; pub use error::handle_error_response; -pub use compose::{AllAuthProvider, AnyAuthProvider, RoutingAuthProvider}; +pub use compose::{AllAuthProvider, AnyAuthProvider, LayeredAuthProvider, RoutingAuthProvider}; pub use credential::AuthCredentialSource; pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, diff --git a/generators/cli/sdk/src/auth/oauth2.rs b/generators/cli/sdk/src/auth/oauth2.rs index 9f761ea61c79..b0755e1287f4 100644 --- a/generators/cli/sdk/src/auth/oauth2.rs +++ b/generators/cli/sdk/src/auth/oauth2.rs @@ -101,7 +101,7 @@ struct CachedToken { /// The file is a JSON object keyed by token_url: /// ```json /// { -/// "https://identity.example.com/connect/token": { +/// "https://identity.xero.com/connect/token": { /// "access_token": "...", /// "refresh_token": "...", /// "expires_at": 1715550000 @@ -492,7 +492,7 @@ impl OAuth2TokenProvider { } /// Enable on-disk token persistence. `cli_name` is the binary name - /// (e.g., `"myapi"`) — tokens are stored under the platform config dir. + /// (e.g., `"xero"`) — tokens are stored under the platform config dir. pub fn with_cache(mut self, cli_name: &str) -> Self { self.cache = TokenCache::for_cli(cli_name); self @@ -659,6 +659,28 @@ impl AuthProvider for OAuth2TokenProvider { } } + fn credential_hints(&self) -> Vec { + match &self.grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + .. + } => vec![ + format!("{client_id_env} environment variable"), + format!("{client_secret_env} environment variable"), + ], + OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + refresh_token_env, + } => vec![ + format!("{client_id_env} environment variable"), + format!("{client_secret_env} environment variable"), + format!("{refresh_token_env} environment variable"), + ], + } + } + fn apply( &self, request: reqwest::RequestBuilder, diff --git a/generators/cli/sdk/src/auth/provider.rs b/generators/cli/sdk/src/auth/provider.rs index 9ab3470d3e7b..8cbec6b950ae 100644 --- a/generators/cli/sdk/src/auth/provider.rs +++ b/generators/cli/sdk/src/auth/provider.rs @@ -104,6 +104,13 @@ pub trait AuthProvider: Send + Sync + std::fmt::Debug { self.has_credentials() } + /// Human-readable hints about where this provider reads its credentials + /// from. Used by the friendly auth-error path to tell the user which + /// env var / CLI flag / file to set. + fn credential_hints(&self) -> Vec { + Vec::new() + } + /// Apply the scheme to `request`. Implementations should be a no-op if /// they can't satisfy the request (e.g., no env var set), so wrappers can /// fall through. Hard errors (malformed token bytes) are surfaced via diff --git a/generators/cli/sdk/src/auth/root_builder.rs b/generators/cli/sdk/src/auth/root_builder.rs index 8365b0db1f6f..97e2f7603690 100644 --- a/generators/cli/sdk/src/auth/root_builder.rs +++ b/generators/cli/sdk/src/auth/root_builder.rs @@ -415,5 +415,4 @@ mod tests { assert_eq!(name, "OAuth2Security"); assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); } - } diff --git a/generators/cli/sdk/src/auth/schemes.rs b/generators/cli/sdk/src/auth/schemes.rs index db98d297271b..c5704e48f2b3 100644 --- a/generators/cli/sdk/src/auth/schemes.rs +++ b/generators/cli/sdk/src/auth/schemes.rs @@ -52,6 +52,10 @@ impl AuthProvider for BearerAuthProvider { self.token.resolve().is_some() } + fn credential_hints(&self) -> Vec { + self.token.credential_hints() + } + fn apply( &self, request: reqwest::RequestBuilder, @@ -121,7 +125,7 @@ impl BasicAuthProvider { } /// Username-only Basic auth (empty password). Common for APIs that - /// accept an API key as the HTTP Basic username. + /// accept an API key as the HTTP Basic username (e.g. Close CRM). pub fn username_only( name: impl Into, username: AuthCredentialSource, @@ -164,6 +168,12 @@ impl AuthProvider for BasicAuthProvider { } } + fn credential_hints(&self) -> Vec { + let mut hints = self.username.credential_hints(); + hints.extend(self.password.credential_hints()); + hints + } + fn apply( &self, request: reqwest::RequestBuilder, @@ -203,13 +213,13 @@ impl AuthProvider for BasicAuthProvider { // HeaderAuthProvider — raw or bearer-prefixed token in a named header. // --------------------------------------------------------------------------- -/// Send the token verbatim in a named header. Used by APIs that pass the -/// raw token in `Authorization` (no `Bearer ` prefix) and any custom +/// Send the token verbatim in a named header. Used by APIs like Linear +/// (`Authorization: ` with no `Bearer ` prefix) and any custom /// `X-Api-Key` style scheme. /// /// If `bearer_prefix` is true, the value is prefixed with `Bearer ` — /// equivalent to a [`BearerAuthProvider`] but on a non-`Authorization` -/// header. +/// header (the Square pattern). #[derive(Debug, Clone)] pub struct HeaderAuthProvider { name: String, @@ -243,6 +253,10 @@ impl AuthProvider for HeaderAuthProvider { self.token.resolve().is_some() } + fn credential_hints(&self) -> Vec { + self.token.credential_hints() + } + fn apply( &self, request: reqwest::RequestBuilder, diff --git a/generators/cli/sdk/src/graphql/app.rs b/generators/cli/sdk/src/graphql/app.rs index b04c4a6cf262..7e477c2bc679 100644 --- a/generators/cli/sdk/src/graphql/app.rs +++ b/generators/cli/sdk/src/graphql/app.rs @@ -295,6 +295,7 @@ impl AppContext { false, None, &entry.http_config, + false, )) .map(|_| ()) } diff --git a/generators/cli/sdk/src/graphql/binding.rs b/generators/cli/sdk/src/graphql/binding.rs index 2b732510efa4..b74d78f58946 100644 --- a/generators/cli/sdk/src/graphql/binding.rs +++ b/generators/cli/sdk/src/graphql/binding.rs @@ -22,6 +22,12 @@ struct Prepared { #[must_use] pub struct GraphqlBinding { inner: super::CliApp, + /// When set, the entire GraphQL surface is mounted under this single + /// top-level command (e.g. `graphql`) instead of contributing its + /// resource groups directly at the root. Lets a GraphQL binding + /// coexist with another binding whose group names would otherwise + /// collide (e.g. an OpenAPI binding for the same vendor). + prefix: Option, prepared: std::sync::Mutex>>, } @@ -29,6 +35,7 @@ impl Default for GraphqlBinding { fn default() -> Self { Self { inner: super::CliApp::new(""), + prefix: None, prepared: std::sync::Mutex::new(None), } } @@ -51,6 +58,17 @@ impl GraphqlBinding { self } + /// Mount the entire GraphQL surface under a single top-level command. + /// + /// Without this, the binding contributes its resource groups directly + /// at the CLI root (` payment …`). With `.under("graphql")`, the + /// same surface is reachable as ` graphql payment …`, freeing the + /// root namespace for another binding (e.g. a REST `OpenApiBinding`). + pub fn under(mut self, prefix: &str) -> Self { + self.prefix = Some(prefix.to_string()); + self + } + pub fn auth_scheme_env(mut self, scheme_name: &str, env_var: &str) -> Self { self.inner = self.inner.auth_scheme_env(scheme_name, env_var); self @@ -61,16 +79,6 @@ impl GraphqlBinding { self } - pub fn auth_basic_scheme( - mut self, - scheme_name: &str, - username: AuthCredentialSource, - password: AuthCredentialSource, - ) -> Self { - self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); - self - } - pub fn auth_provider( mut self, scheme_name: &str, @@ -94,7 +102,20 @@ impl GraphqlBinding { "No endpoint provided. Call .endpoint() on GraphqlBinding.".to_string(), ) })?; - let doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + let mut doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + // If a prefix is configured, nest every top-level resource under a + // single synthetic resource. `build_cli`, `resolve_method_from_matches`, + // and the JSON-help walk all recurse through `resources`, so this is + // all that's needed to mount the whole surface under ``. + if let Some(prefix) = &self.prefix { + let original = std::mem::take(&mut doc.resources); + let wrapper = crate::graphql::discovery::GraphQLResource { + methods: std::collections::HashMap::new(), + resources: original, + }; + doc.resources = std::collections::HashMap::from([(prefix.clone(), wrapper)]); + } let http_config = crate::http::HttpConfig::new(&self.inner.name)? .with_parsed_root_certs( @@ -269,6 +290,15 @@ impl Binding for GraphqlBinding { let dry_run = matched_args.get_flag("dry-run"); let pagination = super::app::build_pagination_config(matched_args); + // `--no-retry` is a global debug opt-out; read it safely so an + // unmatched flag is a clean `false` rather than a panic. + let no_retry = matched_args + .try_get_one::("no-retry") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let base_url_override_owned = crate::cli_args::resolve_base_url_override(root_matches, &self.inner.name)?; let base_url_override = base_url_override_owned.as_deref(); @@ -285,6 +315,7 @@ impl Binding for GraphqlBinding { true, // capture_output base_url_override, &prepared.http_config, + no_retry, ) .await?; @@ -353,3 +384,60 @@ impl Binding for GraphqlBinding { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::binding::Binding; + + // A compact GraphQL introspection schema (the graphql-fixture surface) + // with two top-level resources: `node` and `filtered-node`. + const FIXTURE_SCHEMA: &str = include_str!("../../cli/graphql-fixture/schema.json"); + + fn prepared_binding(prefix: Option<&str>) -> GraphqlBinding { + let mut b = GraphqlBinding::new() + .spec(FIXTURE_SCHEMA) + .endpoint("http://localhost/graphql"); + if let Some(p) = prefix { + b = b.under(p); + } + b.set_cli_name("fixture"); + b + } + + #[test] + fn without_prefix_resources_are_top_level() { + let cmd = prepared_binding(None).build_command().unwrap(); + let names: Vec<_> = cmd.get_subcommands().map(|c| c.get_name()).collect(); + assert!(names.contains(&"node"), "expected `node` at root, got {names:?}"); + assert!( + !names.contains(&"graphql"), + "did not expect a `graphql` wrapper without .under(), got {names:?}" + ); + } + + #[test] + fn under_prefix_nests_all_resources() { + let cmd = prepared_binding(Some("graphql")).build_command().unwrap(); + let names: Vec<_> = cmd.get_subcommands().map(|c| c.get_name()).collect(); + assert!( + names.contains(&"graphql"), + "expected a top-level `graphql` wrapper, got {names:?}" + ); + assert!( + !names.contains(&"node"), + "resources should be nested under `graphql`, not at root: {names:?}" + ); + + // The original resources live one level down, under the wrapper. + let wrapper = cmd + .get_subcommands() + .find(|c| c.get_name() == "graphql") + .expect("graphql wrapper present"); + let nested: Vec<_> = wrapper.get_subcommands().map(|c| c.get_name()).collect(); + assert!( + nested.contains(&"node") && nested.contains(&"filtered-node"), + "expected node/filtered-node nested under graphql, got {nested:?}" + ); + } +} diff --git a/generators/cli/sdk/src/graphql/commands.rs b/generators/cli/sdk/src/graphql/commands.rs index a65076c45209..7b6623fdc76b 100644 --- a/generators/cli/sdk/src/graphql/commands.rs +++ b/generators/cli/sdk/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "no-retry", "quiet", "help", ]; @@ -61,6 +62,16 @@ pub fn build_cli(doc: &RestDescription) -> Command { .help("Suppress stdout output on success (errors still go to stderr)") .action(clap::ArgAction::SetTrue) .global(true), + ) + .arg( + clap::Arg::new("no-retry") + .long("no-retry") + .help( + "Disable automatic retries on transient failures (5xx, 408, 429, \ + network errors). Useful for debugging.", + ) + .action(clap::ArgAction::SetTrue) + .global(true), ); // Add resource subcommands diff --git a/generators/cli/sdk/src/graphql/executor.rs b/generators/cli/sdk/src/graphql/executor.rs index dab3e92329ae..3554c70f7bf6 100644 --- a/generators/cli/sdk/src/graphql/executor.rs +++ b/generators/cli/sdk/src/graphql/executor.rs @@ -206,6 +206,7 @@ pub async fn execute_method( capture_output: bool, base_url_override: Option<&str>, http_config: &crate::http::HttpConfig, + no_retry: bool, ) -> Result, CliError> { let mut input = parse_and_validate_inputs(doc, method, params_json, body_json, base_url_override)?; @@ -230,26 +231,89 @@ pub async fn execute_method( let mut pages_fetched: u32 = 0; let mut captured_values = Vec::new(); - // Build the client once outside the pagination loop. Client construction - // reads env vars and (with TLS) builds a connection pool; rebuilding per - // page would defeat connection reuse and emit any one-time warnings - // (e.g. insecure-mode) once per page. let client = http_config.build_client()?; - loop { - let request = build_http_request(&client, &input, auth_provider)?; + let retry_policy = crate::http::RetryPolicy::default(); + loop { let method_id = method.id.as_deref().unwrap_or("unknown"); let start = std::time::Instant::now(); - let response = match request.send().await { - Ok(resp) => resp, - Err(e) => { - // Surface a human-readable hint to stderr if this looks like - // a TLS failure — the most common debugging hump for users - // behind corporate proxies / interception tools. The hint is - // a side effect; the error then propagates up like any other. - crate::http::maybe_emit_tls_hint(http_config, &e); - return Err(anyhow::Error::from(e).context("HTTP request failed").into()); + + // Fresh key per page so the server doesn't deduplicate distinct + // pages, but stable across retries of the same page. + let idempotency_key = Some(crate::http::generate_idempotency_key()); + + // Retry loop — same pattern as the OpenAPI executor. + let mut retry_attempt: u32 = 0; + let response = loop { + let mut request = build_http_request(&client, &input, auth_provider)?; + if let Some(ref key) = idempotency_key { + request = request.header("Idempotency-Key", key.as_str()); + } + + match request.send().await { + Ok(resp) => { + let status = resp.status(); + let retry_after_header = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let outcome = crate::http::RetryOutcome { + status: Some(status.as_u16()), + retry_after: retry_after_header.as_deref(), + }; + if let Some(delay) = crate::http::decide_retry( + retry_attempt, + &outcome, + &retry_policy, + "POST", + idempotency_key.is_some(), + no_retry, + ) { + tracing::warn!( + api_method = method_id, + http_method = "POST", + status = status.as_u16(), + attempt = retry_attempt + 1, + delay_ms = delay.as_millis() as u64, + "retrying after retryable HTTP status", + ); + let _ = resp.bytes().await; + tokio::time::sleep(delay).await; + retry_attempt += 1; + continue; + } + break resp; + } + Err(e) => { + let outcome = crate::http::RetryOutcome { + status: None, + retry_after: None, + }; + if let Some(delay) = crate::http::decide_retry( + retry_attempt, + &outcome, + &retry_policy, + "POST", + idempotency_key.is_some(), + no_retry, + ) { + tracing::warn!( + api_method = method_id, + http_method = "POST", + attempt = retry_attempt + 1, + delay_ms = delay.as_millis() as u64, + error = %e, + "retrying after network/transport failure", + ); + tokio::time::sleep(delay).await; + retry_attempt += 1; + continue; + } + crate::http::maybe_emit_tls_hint(http_config, &e); + return Err(anyhow::Error::from(e).context("HTTP request failed").into()); + } } }; let latency_ms = start.elapsed().as_millis() as u64; @@ -669,6 +733,7 @@ mod tests { true, // capture_output None, &http_config, + false, ) .await .expect("dry-run should succeed"); diff --git a/generators/cli/sdk/src/graphql/parser.rs b/generators/cli/sdk/src/graphql/parser.rs index 2b956c4bbe08..1318e44ab9ec 100644 --- a/generators/cli/sdk/src/graphql/parser.rs +++ b/generators/cli/sdk/src/graphql/parser.rs @@ -971,4 +971,21 @@ mod tests { ); } + #[test] + fn test_load_linear_schema() { + let json = include_str!("../../cli/linear/schema.json"); + let doc = load_graphql_schema(json, "linear", "https://api.linear.app/graphql").unwrap(); + assert_eq!(doc.name, "linear"); + + let issue = doc.resources.get("issue").expect("issue resource missing"); + assert!(issue.methods.contains_key("get"), "missing issue.get"); + assert!(issue.methods.contains_key("list"), "missing issue.list"); + assert!(issue.methods.contains_key("create"), "missing issue.create"); + + assert!( + doc.resources.len() > 20, + "expected >20 resources, got {}", + doc.resources.len() + ); + } } diff --git a/generators/cli/sdk/src/http.rs b/generators/cli/sdk/src/http.rs index 4d7ee2f14ef2..69fe34940083 100644 --- a/generators/cli/sdk/src/http.rs +++ b/generators/cli/sdk/src/http.rs @@ -50,7 +50,7 @@ use crate::error::CliError; /// and threads it through to the executor. #[derive(Clone, Debug)] pub struct HttpConfig { - /// CLI binary name (e.g. `"myapi"`). Cheap to clone via `Arc`. + /// CLI binary name (e.g. `"bigcommerce"`). Cheap to clone via `Arc`. name: Arc, /// Env-var prefix derived once from `name`: uppercase + `-` → `_`. Cached /// so the transform isn't recomputed on every `build_client` call (and @@ -161,13 +161,13 @@ impl HttpConfig { self } - /// CLI binary name (e.g. `"myapi"`). + /// CLI binary name (e.g. `"bigcommerce"`). pub fn name(&self) -> &str { &self.name } /// Env-var prefix derived from the binary name (uppercase, `-` → `_`). - /// `MYAPI`, `OTHER_API`, etc. Use this when constructing scoped env vars + /// `BIGCOMMERCE`, `BOX`, etc. Use this when constructing scoped env vars /// so the transform stays consistent across the codebase. pub fn env_prefix(&self) -> &str { &self.prefix @@ -424,8 +424,8 @@ fn is_first_emission(name: &str, kind: &str) -> bool { // Env-var helpers // ---------------------------------------------------------------------------- -/// Format a scoped env-var name. `scoped("MYAPI", "_CA_BUNDLE")` → -/// `"MYAPI_CA_BUNDLE"`. +/// Format a scoped env-var name. `scoped("BIGCOMMERCE", "_CA_BUNDLE")` → +/// `"BIGCOMMERCE_CA_BUNDLE"`. fn scoped(prefix: &str, suffix: &str) -> String { format!("{prefix}{suffix}") } @@ -468,6 +468,226 @@ fn parse_secs(key: &str) -> Option { std::env::var(key).ok().and_then(|v| v.parse().ok()) } +// ---------------------------------------------------------------------------- +// Retry / Idempotency — shared infrastructure +// ---------------------------------------------------------------------------- + +/// Default retry policy for the CLI. Used by both OpenAPI and GraphQL +/// executors when no spec-level `x-fern-retries` override applies. +/// +/// 4 total attempts (initial + 3 retries), exponential backoff starting +/// at 500ms with factor 2, 10% jitter. +#[derive(Debug, Clone, PartialEq)] +pub struct RetryPolicy { + pub enabled: bool, + pub max_attempts: u32, + pub base_delay_ms: u64, + pub factor: f64, + pub jitter: f64, +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + enabled: true, + max_attempts: 4, + base_delay_ms: 500, + factor: 2.0, + jitter: 0.1, + } + } +} + +impl RetryPolicy { + pub fn disabled() -> Self { + Self { + enabled: false, + max_attempts: 0, + base_delay_ms: 0, + factor: 2.0, + jitter: 0.0, + } + } +} + +/// Returns `true` when the HTTP status code is considered retryable. +/// +/// Retryable statuses: +/// - 408 Request Timeout +/// - 429 Too Many Requests +/// - 500–599 (all server errors) +pub fn is_retryable_status(status: u16) -> bool { + status == 408 || status == 429 || (500..=599).contains(&status) +} + +/// Whether the HTTP method is safe to retry without explicit idempotency +/// marking. GET, HEAD, OPTIONS, DELETE, and PUT are idempotent by spec. +pub fn method_allows_retry(http_method: &str, marked_idempotent: bool) -> bool { + if marked_idempotent { + return true; + } + matches!( + http_method.to_ascii_uppercase().as_str(), + "GET" | "HEAD" | "OPTIONS" | "DELETE" | "PUT" + ) +} + +/// Parse a `Retry-After` header value into a `Duration`. +/// +/// Accepts either a non-negative integer (seconds) or an HTTP-date +/// (IMF-fixdate / RFC 850 / asctime). +pub fn parse_retry_after(value: &str, now: std::time::SystemTime) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(secs) = trimmed.parse::() { + return Some(Duration::from_secs(secs)); + } + if let Ok(target) = httpdate::parse_http_date(trimmed) { + return Some(target.duration_since(now).unwrap_or(Duration::ZERO)); + } + None +} + +/// Compute exponential backoff delay for the given attempt. +/// +/// `attempt` is 0-indexed (the just-completed send). Delay grows as +/// `base_delay_ms * factor^attempt`, with symmetric jitter. +pub fn compute_backoff_delay(attempt: u32, policy: &RetryPolicy) -> Duration { + let jitter_sample = if policy.jitter > 0.0 { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as u64; + ((nanos.wrapping_mul(2654435761)) & 0xFFFF) as f64 / 65535.0 + } else { + 0.5 + }; + compute_backoff_delay_with_rand(attempt, policy, jitter_sample) +} + +/// Test-friendly variant: `rand_unit` in `[0.0, 1.0]`; 0.5 = no jitter offset. +pub fn compute_backoff_delay_with_rand( + attempt: u32, + policy: &RetryPolicy, + rand_unit: f64, +) -> Duration { + if !policy.enabled { + return Duration::ZERO; + } + let raw_ms = (policy.base_delay_ms as f64) * policy.factor.powi(attempt as i32); + let jitter_span = raw_ms * policy.jitter; + let offset = (rand_unit.clamp(0.0, 1.0) - 0.5) * jitter_span; + let ms = (raw_ms + offset).max(0.0); + let capped = if ms > u64::MAX as f64 { u64::MAX } else { ms as u64 }; + Duration::from_millis(capped) +} + +/// Outcome of a retry-loop iteration. +#[derive(Debug)] +pub struct RetryOutcome<'a> { + pub status: Option, + pub retry_after: Option<&'a str>, +} + +/// Decide whether to retry. Returns `Some(delay)` to schedule a retry, +/// or `None` to surface the outcome. +pub fn decide_retry( + attempt: u32, + outcome: &RetryOutcome<'_>, + policy: &RetryPolicy, + http_method: &str, + marked_idempotent: bool, + no_retry: bool, +) -> Option { + if no_retry || !policy.enabled || policy.max_attempts == 0 { + return None; + } + if attempt + 1 >= policy.max_attempts { + return None; + } + match outcome.status { + None => { + if !method_allows_retry(http_method, marked_idempotent) { + return None; + } + Some(compute_backoff_delay(attempt, policy)) + } + Some(status) => { + if !is_retryable_status(status) { + return None; + } + let always_safe = matches!(status, 408 | 429); + if !always_safe && !method_allows_retry(http_method, marked_idempotent) { + return None; + } + if let Some(raw) = outcome.retry_after { + if let Some(d) = parse_retry_after(raw, std::time::SystemTime::now()) { + return Some(d); + } + } + Some(compute_backoff_delay(attempt, policy)) + } + } +} + +/// Generate a UUID v4 idempotency key. +/// +/// Uses cheap entropy from system time + process-id + a monotonic counter +/// rather than pulling in the `uuid` crate. The counter guarantees +/// uniqueness even when two calls land in the same clock tick (e.g. fast +/// pagination with `--page-delay 0`). The result is formatted as a +/// standard 8-4-4-4-12 lowercase hex UUID with version nibble = 4 and +/// variant bits set per RFC 4122 section 4.4. +pub fn generate_idempotency_key() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let nanos = now.subsec_nanos() as u64; + let secs = now.as_secs(); + let pid = std::process::id() as u64; + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + + // Mix bits with a multiplicative hash (Knuth's golden-ratio constant + // variants) to spread entropy across the 128-bit space. + let a = secs + .wrapping_mul(6364136223846793005) + .wrapping_add(nanos) + .wrapping_add(seq); + let b = nanos + .wrapping_mul(2654435761) + .wrapping_add(pid) + .wrapping_mul(1442695040888963407) + .wrapping_add(secs) + .wrapping_add(seq.wrapping_mul(6364136223846793005)); + + // Stamp version (4) and variant (10xx) bits per RFC 4122. + let hi = (a & 0xFFFFFFFF_FFFF0FFF) | 0x00000000_00004000; + let lo = (b & 0x3FFFFFFF_FFFFFFFF) | 0x80000000_00000000; + + format!( + "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", + (hi >> 32) as u32, + ((hi >> 16) & 0xFFFF) as u16, + hi as u16, + (lo >> 48) as u16, + lo & 0x0000FFFFFFFFFFFF, + ) +} + +/// Returns `true` when the HTTP method should carry an auto-generated +/// `Idempotency-Key` header (POST, PUT, PATCH). +pub fn needs_idempotency_key(http_method: &str) -> bool { + matches!( + http_method.to_ascii_uppercase().as_str(), + "POST" | "PUT" | "PATCH" + ) +} + // ---------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------- @@ -531,7 +751,7 @@ mod tests { #[test] #[serial_test::serial] fn build_client_succeeds_with_clean_env() { - let cfg = HttpConfig::new("myapi").unwrap(); + let cfg = HttpConfig::new("bigcommerce").unwrap(); assert!(cfg.build_client().is_ok()); } @@ -674,8 +894,8 @@ mod tests { #[test] fn scoped_helper_concatenates_prefix_and_suffix() { - assert_eq!(scoped("MYAPI", "_CA_BUNDLE"), "MYAPI_CA_BUNDLE"); - assert_eq!(scoped("OTHER", "_INSECURE"), "OTHER_INSECURE"); + assert_eq!(scoped("BIGCOMMERCE", "_CA_BUNDLE"), "BIGCOMMERCE_CA_BUNDLE"); + assert_eq!(scoped("BOX", "_INSECURE"), "BOX_INSECURE"); } // ----- resolve() — transport-neutral view --------------------------------- @@ -842,4 +1062,130 @@ Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc\n\ assert_eq!(resolved.request_timeout, Some(Duration::from_secs(30))); assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(5))); } + + // --------------------------------------------------------------- + // Retry policy tests + // --------------------------------------------------------------- + + #[test] + fn retry_policy_default_has_4_attempts() { + let p = RetryPolicy::default(); + assert!(p.enabled); + assert_eq!(p.max_attempts, 4); + assert_eq!(p.base_delay_ms, 500); + } + + #[test] + fn is_retryable_status_covers_5xx_408_429() { + for s in [408u16, 429, 500, 501, 502, 503, 504, 599] { + assert!(is_retryable_status(s), "{s} should be retryable"); + } + for s in [200u16, 301, 400, 401, 403, 404, 422] { + assert!(!is_retryable_status(s), "{s} should NOT be retryable"); + } + } + + #[test] + fn method_allows_retry_idempotent_verbs() { + for m in ["GET", "HEAD", "OPTIONS", "DELETE", "PUT"] { + assert!(method_allows_retry(m, false), "{m} should allow retry"); + } + for m in ["POST", "PATCH"] { + assert!(!method_allows_retry(m, false), "{m} should NOT allow retry"); + assert!(method_allows_retry(m, true), "{m}+marked should allow"); + } + } + + #[test] + fn needs_idempotency_key_post_put_patch() { + assert!(needs_idempotency_key("POST")); + assert!(needs_idempotency_key("PUT")); + assert!(needs_idempotency_key("PATCH")); + assert!(!needs_idempotency_key("GET")); + assert!(!needs_idempotency_key("DELETE")); + } + + #[test] + fn generate_idempotency_key_is_uuid_shaped() { + let key = generate_idempotency_key(); + let parts: Vec<&str> = key.split('-').collect(); + assert_eq!(parts.len(), 5, "should be 8-4-4-4-12 format: {key}"); + assert_eq!(parts[0].len(), 8); + assert_eq!(parts[1].len(), 4); + assert_eq!(parts[2].len(), 4); + assert_eq!(parts[3].len(), 4); + assert_eq!(parts[4].len(), 12); + // Version nibble = 4 + assert!(parts[2].starts_with('4'), "version nibble should be 4: {key}"); + } + + #[test] + fn generate_idempotency_key_unique() { + // No sleep needed -- the monotonic counter guarantees uniqueness + // even when calls land in the same clock tick. + let a = generate_idempotency_key(); + let b = generate_idempotency_key(); + assert_ne!(a, b, "successive keys should differ"); + } + + #[test] + fn parse_retry_after_numeric() { + let now = std::time::SystemTime::now(); + assert_eq!(parse_retry_after("5", now), Some(Duration::from_secs(5))); + assert_eq!(parse_retry_after("0", now), Some(Duration::from_secs(0))); + } + + #[test] + fn parse_retry_after_empty_returns_none() { + let now = std::time::SystemTime::now(); + assert_eq!(parse_retry_after("", now), None); + assert_eq!(parse_retry_after(" ", now), None); + } + + #[test] + fn compute_backoff_delay_grows_exponentially() { + let p = RetryPolicy { + enabled: true, + max_attempts: 4, + base_delay_ms: 500, + factor: 2.0, + jitter: 0.0, + }; + let d0 = compute_backoff_delay_with_rand(0, &p, 0.5); + let d1 = compute_backoff_delay_with_rand(1, &p, 0.5); + let d2 = compute_backoff_delay_with_rand(2, &p, 0.5); + assert_eq!(d0, Duration::from_millis(500)); + assert_eq!(d1, Duration::from_millis(1000)); + assert_eq!(d2, Duration::from_millis(2000)); + } + + #[test] + fn decide_retry_no_retry_flag_short_circuits() { + let p = RetryPolicy::default(); + let outcome = RetryOutcome { status: Some(503), retry_after: None }; + assert!(decide_retry(0, &outcome, &p, "GET", false, true).is_none()); + } + + #[test] + fn decide_retry_exhausts_max_attempts() { + let p = RetryPolicy { max_attempts: 3, ..RetryPolicy::default() }; + let outcome = RetryOutcome { status: Some(503), retry_after: None }; + assert!(decide_retry(0, &outcome, &p, "GET", false, false).is_some()); + assert!(decide_retry(1, &outcome, &p, "GET", false, false).is_some()); + assert!(decide_retry(2, &outcome, &p, "GET", false, false).is_none()); + } + + #[test] + fn decide_retry_post_5xx_without_idempotent_no_retry() { + let p = RetryPolicy::default(); + let outcome = RetryOutcome { status: Some(503), retry_after: None }; + assert!(decide_retry(0, &outcome, &p, "POST", false, false).is_none()); + } + + #[test] + fn decide_retry_post_429_always_safe() { + let p = RetryPolicy::default(); + let outcome = RetryOutcome { status: Some(429), retry_after: None }; + assert!(decide_retry(0, &outcome, &p, "POST", false, false).is_some()); + } } diff --git a/generators/cli/sdk/src/openapi/app.rs b/generators/cli/sdk/src/openapi/app.rs index e0dcfb9e7deb..8daf8441732d 100644 --- a/generators/cli/sdk/src/openapi/app.rs +++ b/generators/cli/sdk/src/openapi/app.rs @@ -30,7 +30,7 @@ fn split_prefix(prefix: &str) -> Vec { /// **Stutter elision:** at the leaf, if `incoming` contains a top-level /// resource whose name matches the leaf namespace, that resource's methods /// and sub-resources are *hoisted* into the namespace itself — eliminating -/// the `myapi v3 customers customers get` repetition that would +/// the `bigcommerce v3 customers customers get` repetition that would /// otherwise occur when a spec's primary domain matches the namespace name. /// Other top-level resources from the spec become children of the /// namespace as usual. @@ -204,7 +204,7 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups share common schema + // Multi-spec setups like BigCommerce's Management API share common schema // names (`ErrorResponse`, `Pagination`, `Meta`) across many specs that are // authored from the same template — collisions are the norm, not a bug. // First write wins; schemas are only used for best-effort request-body @@ -482,16 +482,16 @@ pub(crate) struct SpecEntry { } /// A server-URL template variable like `{store_hash}` in -/// `https://api.example.com/stores/{store_hash}/v3`. Resolved at runtime +/// `https://api.bigcommerce.com/stores/{store_hash}/v3`. Resolved at runtime /// from a CLI flag (`--`), an env var, or a built-in default — first /// match wins. #[derive(Clone)] pub(crate) struct ServerVar { /// OpenAPI variable name as it appears in the URL template (`store_hash`). name: String, - /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). + /// Env var consulted when the flag isn't passed (e.g. `BIGCOMMERCE_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — most + /// Fallback default (for variables that have one — most BigCommerce-style /// store identifiers don't). default: Option, /// One-line `--help` string. @@ -514,6 +514,12 @@ pub struct CliApp { /// — the spec drives the choice. Generators that already know the /// API's auth model can pin a specific strategy. auth_strategy: AuthStrategy, + /// Optional additive auth layers registered via + /// [`auth_layer`](Self::auth_layer). Each is applied on top of the + /// composed primary provider whenever it has credentials — see + /// [`LayeredAuthProvider`](crate::auth::LayeredAuthProvider). Empty by + /// default; layers never affect whether the primary auth is satisfiable. + pub(crate) auth_layers: Vec, /// Trust roots parsed at builder-call time. Storing parsed certs (not /// raw bytes) means the validation error message lives in one place /// — at the call site of `extra_root_cert`, where it's most useful. @@ -555,6 +561,7 @@ impl CliApp { description_override: None, auth_bindings: Vec::new(), auth_strategy: AuthStrategy::Auto, + auth_layers: Vec::new(), extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), server_vars: Vec::new(), @@ -636,7 +643,7 @@ impl CliApp { /// 4. Otherwise, errors with a helpful message /// /// Used for multi-tenant APIs where every URL is parameterized — the - /// canonical example is a `{store_hash}` placeholder. Variables + /// canonical example is BigCommerce's `{store_hash}`. Variables /// referenced in `servers[].url` but not registered here remain literal /// in the URL (and the request will fail at send time), so registering /// them is effectively required. @@ -769,7 +776,7 @@ impl CliApp { /// deep-merged onto the spec before parsing. /// /// ```ignore - /// CliApp::new("myapi") + /// CliApp::new("bigcommerce") /// .specs_under_named_with_overrides("v3", [ /// ("customers", /// include_str!("specs/management/customers.v3.yml"), @@ -1017,6 +1024,48 @@ impl CliApp { self } + /// Register an *additive* auth layer: a provider whose headers are + /// attached on top of the composed primary auth whenever it has + /// credentials, regardless of the [`AuthStrategy`]. + /// + /// Unlike [`auth_scheme`](Self::auth_scheme) bound under + /// [`AuthStrategy::All`], a layer is strictly optional — it never makes + /// the primary auth mandatory and is silently skipped when its credential + /// is absent. Use it for a supplementary header that sits alongside real + /// auth and is only present in some environments. + /// + /// The motivating case is Lattice Sandboxes, which require an extra + /// `Anduril-Sandbox-Authorization: Bearer ` header in addition to + /// the normal bearer token — but only when developing against a sandbox: + /// + /// ```ignore + /// use fern_cli_sdk::auth::{AuthCredentialSource, HeaderAuthProvider}; + /// + /// CliApp::new("lattice") + /// .spec(include_str!("openapi.yaml")) + /// .auth_scheme_env("bearerHttpAuthentication", "ENVIRONMENT_TOKEN") + /// .auth_layer(HeaderAuthProvider::new( + /// "sandboxAuthorization", + /// "Anduril-Sandbox-Authorization", + /// AuthCredentialSource::from_env("SANDBOXES_TOKEN"), + /// true, // emit "Bearer " + /// )) + /// .run(); + /// ``` + pub fn auth_layer

(self, provider: P) -> Self + where + P: crate::auth::AuthProvider + 'static, + { + self.auth_layer_shared(std::sync::Arc::new(provider)) + } + + /// Same as [`auth_layer`](Self::auth_layer) but takes an already-built + /// [`DynAuthProvider`] (for sharing one provider across bindings). + pub fn auth_layer_shared(mut self, provider: DynAuthProvider) -> Self { + self.auth_layers.push(provider); + self + } + /// Pin how the bound auth schemes compose into a single provider. /// Defaults to [`AuthStrategy::Auto`], which derives the strategy from /// the spec (Routing if any operation declares per-endpoint security, @@ -1071,11 +1120,26 @@ impl CliApp { doc: &RestDescription, mut cli: clap::Command, ) -> clap::Command { - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + let auth_section = { + let base = crate::auth::render_auth_help_section(&self.auth_bindings); + let layer_rows: Vec<(String, Vec)> = self + .auth_layers + .iter() + .map(|p| (p.name().to_string(), p.credential_hints())) + .collect(); + let layers = crate::auth::render_auth_layers_help(&layer_rows); + match (base, layers) { + (Some(b), Some(l)) => Some(format!("{b}{l}")), + (Some(b), None) => Some(b), + // No primary bindings but layers exist — supply the heading. + (None, Some(l)) => Some(format!("Authentication:\n{l}")), + (None, None) => None, + } + }; // Server-variable flags (e.g. `--store-hash` for {store_hash}). for var in &self.server_vars { - let kebab = var.name.replace('_', "-"); + let kebab = crate::text::to_kebab_flag(&var.name); let help_text = var .description .clone() @@ -1265,12 +1329,27 @@ impl CliApp { /// — the CLI runs unauthenticated. pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); - crate::auth::build_provider_with_strategy( + let primary = crate::auth::build_provider_with_strategy( &self.auth_bindings, &doc.security_schemes, self.auth_strategy, has_per_endpoint, - ) + ); + self.wrap_auth_layers(primary) + } + + /// Layer any registered additive providers on top of the composed + /// primary. A no-op when no layers were registered, so the common case + /// pays nothing. + fn wrap_auth_layers(&self, primary: DynAuthProvider) -> DynAuthProvider { + if self.auth_layers.is_empty() { + primary + } else { + std::sync::Arc::new(crate::auth::LayeredAuthProvider::new( + primary, + self.auth_layers.clone(), + )) + } } /// Build an auth provider from externally-finalized bindings. @@ -1282,12 +1361,13 @@ impl CliApp { doc: &RestDescription, ) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); - crate::auth::build_provider_with_strategy( + let primary = crate::auth::build_provider_with_strategy( finalized, &doc.security_schemes, self.auth_strategy, has_per_endpoint, - ) + ); + self.wrap_auth_layers(primary) } } @@ -1319,6 +1399,10 @@ pub struct AppContext { /// `OutputPipeline` by [`AppContext::execute`] so custom commands /// honor the flag. quiet: bool, + /// Base URL override resolved from `--base-url` / `{NAME}_BASE_URL`. + /// Threaded into `invoke()` so custom command handlers respect the + /// override the same way direct CLI dispatch does. + base_url_override: Option, } impl AppContext { @@ -1331,6 +1415,7 @@ impl AppContext { Self { entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], quiet: false, + base_url_override: None, } } @@ -1339,6 +1424,11 @@ impl AppContext { self } + pub(crate) fn with_base_url_override(mut self, base_url_override: Option) -> Self { + self.base_url_override = base_url_override; + self + } + /// Add another binding's prepared state to this context. pub(crate) fn add_entry(&mut self, entry: BindingEntry) { self.entries.push(entry); @@ -1451,11 +1541,12 @@ impl AppContext { None, None, None, + None, // no multipart for programmatic callers false, &pagination, &pipeline, false, - None, + self.base_url_override.as_deref(), &entry.http_config, // TODO(mcp/programmatic): programmatic callers always // honor `x-fern-sdk-return-value` (matches typed-SDK @@ -1489,7 +1580,7 @@ impl AppContext { /// /// Like [`execute`](Self::execute) but captures the response instead of /// printing it, and accepts a `binary_body_path` for operations with a - /// binary request body (e.g. a file upload endpoint). Designed for + /// binary request body (e.g. AssemblyAI's `/v2/upload`). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1528,11 +1619,12 @@ impl AppContext { None, None, binary_body_path, + None, // no multipart for programmatic callers false, &pagination, &formatter::OutputPipeline::default(), true, // capture_output - None, + self.base_url_override.as_deref(), &entry.http_config, // See TODO in `execute` above — same trade-off applies // here: chained custom commands expect the @@ -1750,10 +1842,6 @@ pub(crate) fn collect_params_from_flags( let mut missing_variable_bound: Vec<(String, String)> = Vec::new(); for (param_name, param_def) in &method.parameters { if let Some(var_name) = param_def.variable_reference.as_deref() { - // Global flag ids match the variable name (see `run_async`). - // clap's `.env(...)` on the global arg already covers the - // env-var fallback before we get here, so a missing value - // means neither CLI flag nor env var was provided. match matched_args.get_one::(var_name) { Some(value) => { params.insert( @@ -1767,8 +1855,14 @@ pub(crate) fn collect_params_from_flags( } continue; } + + // The clap arg ID may differ from the wire name when the wire + // name collides with a built-in flag. Use the same resolution + // function the command builder uses (FER-10430). + let arg_id = crate::openapi::commands::param_clap_arg_id(param_name); + if param_def.repeated { - if let Some(values) = matched_args.get_many::(param_name) { + if let Some(values) = matched_args.get_many::(&arg_id) { let arr: Vec = values .map(|v| serde_json::Value::String(v.clone())) .collect(); @@ -1777,18 +1871,29 @@ pub(crate) fn collect_params_from_flags( continue; } - let Some(value) = matched_args.get_one::(param_name) else { + let Some(value) = matched_args.get_one::(&arg_id) else { continue; }; - let from_default = matched_args.value_source(param_name) + let from_default = matched_args.value_source(&arg_id) == Some(clap::parser::ValueSource::DefaultValue); let json_value = match (from_default, ¶m_def.default_value) { (true, Some(typed)) => typed.clone(), _ => { - // For object-typed params (e.g. deepObject query parameters), - // attempt JSON parsing so deepObject serialization receives a - // Value::Object rather than a string. - if param_def.param_type.as_deref() == Some("object") { + // Null sentinel, gated to user-supplied input so a + // default-injected "null" string is never reinterpreted. + // See ADR-0003. + if param_def.nullable && value == "null" { + serde_json::Value::Null + } else if matches!( + param_def.param_type.as_deref(), + Some("object") | Some("array") + ) { + // For object- and array-typed params (e.g. deepObject / + // form / spaceDelimited / pipeDelimited query parameters, + // or simple-style header parameters), attempt JSON parsing + // so the style-aware serializer receives a `Value::Object` / + // `Value::Array` rather than a verbatim string. Falls back + // to the raw string when the value isn't valid JSON. serde_json::from_str(value.as_str()) .unwrap_or_else(|_| serde_json::Value::String(value.clone())) } else { @@ -1829,6 +1934,65 @@ pub(crate) fn collect_params_from_flags( Ok(params) } +/// Collect multipart/form-data parts from CLI arg matches. Returns `None` +/// when the operation has no multipart fields. File-typed fields reject +/// control characters (matching `binary_body_path` validation) but allow +/// absolute paths since users may upload files from anywhere on disk. +pub(crate) fn collect_multipart_parts( + method: &RestMethod, + matches: &clap::ArgMatches, +) -> Result>, crate::error::CliError> { + if method.multipart_fields.is_empty() { + return Ok(None); + } + + let mut parts = Vec::new(); + for field in &method.multipart_fields { + // Skip fields whose kebab name collides with a builtin flag — the + // arg was never registered so any value would come from the builtin. + let kebab = crate::text::to_kebab_flag(&field.wire_name); + if crate::openapi::commands::BUILTIN_FLAG_NAMES.contains(&kebab.as_str()) { + continue; + } + + let value = matches + .try_get_one::(&field.wire_name) + .ok() + .flatten(); + let Some(value) = value else { + continue; + }; + + if field.is_file { + let raw = value.as_str(); + let stripped = raw.strip_prefix('@').unwrap_or(raw); + if stripped != "-" { + crate::output::reject_dangerous_chars( + stripped, + &format!("--{}", crate::text::to_kebab_flag(&field.wire_name)), + )?; + } + parts.push(executor::MultipartPart::File { + name: field.wire_name.clone(), + path: raw.to_string(), + content_type: field.content_type.clone(), + }); + } else { + parts.push(executor::MultipartPart::Text { + name: field.wire_name.clone(), + value: value.clone(), + content_type: field.content_type.clone(), + }); + } + } + + if parts.is_empty() { + Ok(None) + } else { + Ok(Some(parts)) + } +} + pub(crate) fn build_pagination_config( matches: &clap::ArgMatches, doc: &RestDescription, @@ -2568,6 +2732,159 @@ mod tests { assert_eq!(result.get("uuid").unwrap().as_str().unwrap(), "from-json"); } + #[test] + fn test_collect_params_null_sentinel_on_nullable_param() { + // `--user-id null` on a nullable scalar body param must produce + // serde_json::Value::Null, not Value::String("null"). + let mut params = std::collections::HashMap::new(); + params.insert( + "userId".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + nullable: true, + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("userId").long("user-id")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--user-id", "null"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!(result.get("userId"), Some(&serde_json::Value::Null)); + } + + #[test] + fn test_collect_params_null_string_on_non_nullable_param_unchanged() { + // `--field null` on a non-nullable string param keeps current + // behavior: passes the four-character string through unchanged. + let mut params = std::collections::HashMap::new(); + params.insert( + "code".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + nullable: false, + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("code").long("code")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--code", "null"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("code").and_then(|v| v.as_str()), + Some("null"), + "literal 'null' must pass through unchanged on non-nullable fields", + ); + } + + #[test] + fn test_collect_params_array_typed_param_parsed_from_json() { + // An array-typed param (e.g. a simple/array path param) carries a + // JSON-array string on the CLI; it must be parsed into a + // `Value::Array` so the path serializer can comma-join the elements + // instead of sending the verbatim `["a","b"]` string. + let mut params = std::collections::HashMap::new(); + params.insert( + "ids".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("array".to_string()), + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("ids").long("ids")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--ids", r#"["a","b"]"#]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("ids"), + Some(&serde_json::json!(["a", "b"])), + "array-typed param must be JSON-parsed into a Value::Array", + ); + } + + #[test] + fn test_collect_params_array_typed_param_invalid_json_falls_back_to_string() { + // Malformed JSON for an array-typed param keeps the verbatim string + // (no behavior change / no hard error for non-JSON input). + let mut params = std::collections::HashMap::new(); + params.insert( + "ids".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("array".to_string()), + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("ids").long("ids")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--ids", "not-json"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("ids").and_then(|v| v.as_str()), + Some("not-json"), + ); + } + + #[test] + fn test_collect_params_null_sentinel_does_not_apply_to_defaults() { + // When clap injects an `x-fern-default` value that happens to be the + // string "null", we must NOT convert it to Value::Null — the user + // didn't ask for null, the default did. The value_source check is + // load-bearing here. + let mut params = std::collections::HashMap::new(); + params.insert( + "userId".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + nullable: true, + default_value: Some(serde_json::Value::String("fallback".into())), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg( + clap::Arg::new("userId") + .long("user-id") + .default_value("fallback"), + ) + .arg(clap::Arg::new("params").long("params")); + // Omit the flag → clap fills "fallback" from default. + let matches = cmd.get_matches_from(vec!["test"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + // The typed default flows through, NOT a JSON null. + assert_eq!( + result.get("userId"), + Some(&serde_json::Value::String("fallback".into())), + ); + } + #[test] fn test_collect_params_empty_when_no_flags() { let method = crate::openapi::discovery::RestMethod::default(); @@ -2577,6 +2894,71 @@ mod tests { assert!(result.is_empty()); } + #[test] + fn test_collect_params_array_value_parsed_from_json() { + // An `array`-typed param carrying a JSON array string is parsed + // into a Value::Array so the style-aware serializer can explode / + // join it. Previously only `object` params were parsed. + let mut params = std::collections::HashMap::new(); + params.insert( + "tag".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("array".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("tag").long("tag")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--tag", r#"["a","b"]"#]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!(result.get("tag"), Some(&serde_json::json!(["a", "b"]))); + } + + #[test] + fn test_collect_params_array_invalid_json_falls_back_to_string() { + // Non-JSON input for an `array` param degrades to the raw string + // rather than erroring, mirroring the object branch. + let mut params = std::collections::HashMap::new(); + params.insert( + "tag".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("array".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("tag").long("tag")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--tag", "not-json"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("tag").and_then(|v| v.as_str()), + Some("not-json"), + ); + } + + #[test] + fn test_load_spec_via_cli_app() { + let yaml = include_str!("../../cli/box/openapi.yaml"); + let app = CliApp::new("box").spec(yaml); + assert_eq!(app.name, "box"); + // Verify the spec can be parsed via build_doc + let doc = app.build_doc().unwrap(); + assert_eq!(doc.name, "box"); + assert!(doc.resources.len() >= 14); + } + // ------------------------------------------------------------------ // CliApp::idempotency_header_env — generator-side env-var wiring for // FER-9852, implemented in cli-sdk for FER-9864 P1. Verifies the @@ -2927,7 +3309,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). Strict-error policy made multi-spec use + // `Pagination`). Strict-error policy made BigCommerce-style use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3013,7 +3395,7 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Prevents use cases where many v2 specs all need + // Prevents BigCommerce-style use cases where many v2 specs all need // to live under a single `v2` namespace. let spec_a = r#" openapi: "3.0.0" @@ -3141,13 +3523,13 @@ paths: fn test_substitute_url_vars_replaces_known_and_leaves_unknown() { let mut subs = HashMap::new(); subs.insert("store_hash".to_string(), "abc123".to_string()); - let url = "https://api.example.com/stores/{store_hash}/v3/customers/{customer_id}"; + let url = "https://api.bigcommerce.com/stores/{store_hash}/v3/customers/{customer_id}"; let out = substitute_url_vars(url, &subs); // Known var substituted, unknown left literal so the failure mode is // visible in dry-run output and downstream error messages. assert_eq!( out, - "https://api.example.com/stores/abc123/v3/customers/{customer_id}" + "https://api.bigcommerce.com/stores/abc123/v3/customers/{customer_id}" ); } diff --git a/generators/cli/sdk/src/openapi/binding.rs b/generators/cli/sdk/src/openapi/binding.rs index afce90e0a08c..e2c6fcb90d21 100644 --- a/generators/cli/sdk/src/openapi/binding.rs +++ b/generators/cli/sdk/src/openapi/binding.rs @@ -4,7 +4,7 @@ use std::sync::Arc; -use crate::auth::{AuthCredentialSource, DynAuthProvider}; +use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider}; use crate::binding::{Binding, BoxFuture, DispatchResult}; use crate::error::CliError; use crate::openapi::commands; @@ -132,6 +132,33 @@ impl OpenApiBinding { self } + /// Pin how multiple auth schemes compose. See [`AuthStrategy`]. + pub fn auth_strategy(mut self, strategy: AuthStrategy) -> Self { + self.inner = self.inner.auth_strategy(strategy); + self + } + + /// Register an additive auth layer applied on top of the primary auth + /// whenever its credential is present. See [`CliApp::auth_layer`]. + /// + /// [`CliApp::auth_layer`]: crate::openapi::app::CliApp::auth_layer + pub fn auth_layer( + mut self, + provider: impl crate::auth::provider::AuthProvider + 'static, + ) -> Self { + self.inner = self.inner.auth_layer(provider); + self + } + + /// Register a pre-built shared additive auth layer. + /// See [`CliApp::auth_layer_shared`]. + /// + /// [`CliApp::auth_layer_shared`]: crate::openapi::app::CliApp::auth_layer_shared + pub fn auth_layer_shared(mut self, provider: crate::auth::DynAuthProvider) -> Self { + self.inner = self.inner.auth_layer_shared(provider); + self + } + /// Bind HTTP Basic auth for the named scheme. pub fn auth_basic_scheme( mut self, @@ -312,8 +339,8 @@ impl Binding for OpenApiBinding { if !missing.is_empty() { missing.sort(); // Warn rather than fail — multi-spec binaries may intentionally - // bind only a subset of schemes (e.g. basic auth - // but not the OAuth2 schemes). + // bind only a subset of schemes (e.g. Twilio binds basic auth + // but not the IAM OAuth2 schemes). tracing::warn!( "Spec declares security scheme(s) [{}] with no .auth() binding. \ Those endpoints will run unauthenticated.", @@ -501,6 +528,10 @@ impl Binding for OpenApiBinding { }; let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + // Collect multipart/form-data parts from CLI flags for operations + // that declare a `multipart/form-data` body. `None` for all others. + let multipart_parts = super::app::collect_multipart_parts(method, matched_args)?; + // Execute with capture_output = true to get the Value back // instead of printing to stdout. let result = executor::execute_method( @@ -512,6 +543,7 @@ impl Binding for OpenApiBinding { output_path, None, // upload binary_body_path, + multipart_parts, dry_run, &pagination, &crate::formatter::OutputPipeline::default(), @@ -543,12 +575,15 @@ impl Binding for OpenApiBinding { .flatten() .copied() .unwrap_or(false); + let base_url_override = + crate::cli_args::resolve_base_url_override(matches, &self.inner.name)?; let ctx = super::AppContext::new( entry.doc, entry.auth_provider, entry.http_config, entry.global_headers, - ).with_quiet(quiet); + ).with_quiet(quiet) + .with_base_url_override(base_url_override); Ok(Some(Box::new(ctx))) } @@ -564,6 +599,8 @@ impl Binding for OpenApiBinding { .flatten() .copied() .unwrap_or(false); + let base_url_override = + crate::cli_args::resolve_base_url_override(matches, &self.inner.name)?; match existing { Some(ctx_box) => match ctx_box.downcast::() { Ok(mut ctx) => { @@ -578,7 +615,8 @@ impl Binding for OpenApiBinding { entry.auth_provider, entry.http_config, entry.global_headers, - ).with_quiet(quiet); + ).with_quiet(quiet) + .with_base_url_override(base_url_override); let _ = original; Ok(Some(Box::new(ctx))) } @@ -589,7 +627,8 @@ impl Binding for OpenApiBinding { entry.auth_provider, entry.http_config, entry.global_headers, - ).with_quiet(quiet); + ).with_quiet(quiet) + .with_base_url_override(base_url_override); Ok(Some(Box::new(ctx))) } } diff --git a/generators/cli/sdk/src/openapi/commands.rs b/generators/cli/sdk/src/openapi/commands.rs index c5d3897cc368..51509c7c96f1 100644 --- a/generators/cli/sdk/src/openapi/commands.rs +++ b/generators/cli/sdk/src/openapi/commands.rs @@ -5,12 +5,14 @@ use clap::builder::{PossibleValue, PossibleValuesParser}; use clap::{Arg, Command}; +use std::borrow::Cow; use std::collections::HashMap; use crate::openapi::discovery::{ - Availability, FernEnumValue, MethodParameter, RestDescription, RestResource, SdkGroupInfo, + Availability, FernEnumValue, MethodParameter, MultipartField, RestDescription, RestResource, + SdkGroupInfo, }; -use crate::text::to_kebab_flag; +use crate::text::{sanitize_flag_name, to_kebab_flag}; /// Filter the document in-place so only operations matching at least /// one of `active_audiences` survive into the command tree. Mirrors @@ -332,6 +334,17 @@ fn build_resource_command( ); } + // Add per-field flags for multipart/form-data operations. + // Skip fields whose kebab name collides with a builtin flag, + // matching the regular-param convention above. + for field in &method.multipart_fields { + let kebab = to_kebab_flag(&field.wire_name); + if BUILTIN_FLAG_NAMES.contains(&kebab.as_str()) { + continue; + } + method_cmd = method_cmd.arg(build_multipart_field_arg(field)); + } + // Pagination flags method_cmd = method_cmd .arg( @@ -390,50 +403,57 @@ fn build_resource_command( ); } - // Generate individual flags from method parameters + // Generate individual flags from method parameters. + // + // Track (sanitized_flag → wire_name) to detect collisions where + // two distinct wire names produce the same CLI flag. + let mut flag_to_wire: HashMap = HashMap::new(); + let mut param_names: Vec<_> = method.parameters.keys().collect(); param_names.sort(); for param_name in param_names { let param = &method.parameters[param_name]; - // Flag name resolution: - // 1. `flag_name_override` (set verbatim, no kebab pass) — - // populated only by synthetic Fern-extension injections - // (currently `inject_idempotency_header_params`). See - // `MethodParameter::flag_name_override`. - // 2. `display_name` from `x-fern-parameter-name` — kebabed. - // Renames the CLI flag while keeping `param_name` (the - // wire name) as the clap arg ID. Downstream - // `collect_params_from_flags` looks values up by arg ID, - // and the executor uses the params map key (= wire name) - // when serializing the request, so the alias never leaks - // onto the wire — only the user-facing flag changes. - // Mirrors fern's openapi-ir-parser, which renames the - // SDK parameter via `parameterNameOverride` while - // preserving the OpenAPI parameter's `name` on the HTTP - // request. - // 3. Fallback: kebab the HashMap key. - let kebab_name = if let Some(override_flag) = param.flag_name_override.as_deref() { - override_flag.to_string() - } else { - let flag_source = param.display_name.as_deref().unwrap_or(param_name.as_str()); - to_kebab_flag(flag_source) + // Flag name resolution uses `resolve_param_flag_name` — the + // single source of truth shared with the executor's + // missing-param hint (FER-10430). + let kebab_name = match resolve_param_flag_name(param, param_name) { + Some(name) => name, + None => { + tracing::warn!( + param = %param_name, + "skipping parameter with unsanitizable name", + ); + continue; + } }; - if BUILTIN_FLAG_NAMES.contains(&kebab_name.as_str()) { - continue; - } // Variable-bound path parameters get their value from a // root-level global flag (registered in `app::run_async` from - // `doc.sdk_variables`) plus its env-var fallback. Skipping - // here keeps the per-operation flag surface clean and matches - // Fern's openapi-ir-parser, which lowers these into - // constructor-style globals rather than method arguments. + // `doc.sdk_variables`) plus its env-var fallback. Skip before + // inserting into flag_to_wire so variable-bound params don't + // occupy a collision slot and block a later non-variable-bound + // param that sanitizes to the same flag name. if param.variable_reference.is_some() { continue; } - let value_name = match param.param_type.as_deref() { + // Cross-parameter collision: two different wire names mapping + // to the same flag. Skip the second occurrence with a warning + // (load-time error would be ideal but the builder is infallible). + if let Some(existing_wire) = flag_to_wire.get(&kebab_name) { + tracing::warn!( + flag = %kebab_name, + wire1 = %existing_wire, + wire2 = %param_name, + "two parameters sanitize to the same flag --{kebab_name}; \ + keeping '{existing_wire}', skipping '{param_name}'", + ); + continue; + } + flag_to_wire.insert(kebab_name.clone(), param_name.clone()); + + let base_value_name = match param.param_type.as_deref() { Some("string") => "STRING", Some("integer") => "NUMBER", Some("number") => "NUMBER", @@ -442,6 +462,13 @@ fn build_resource_command( Some("object") => "JSON_OBJECT", _ => "VALUE", }; + // Composite types never set `param.nullable`, so the `|null` + // sentinel suffix stays scalar-only without an explicit guard. + let value_name: Cow<'static, str> = if param.nullable { + Cow::Owned(format!("{base_value_name}|null")) + } else { + Cow::Borrowed(base_value_name) + }; let help_text = crate::text::truncate_description( param.description.as_deref().unwrap_or(""), @@ -449,20 +476,22 @@ fn build_resource_command( true, ); let help_text = with_availability_badge(&help_text, param.availability); - // When the flag has been renamed via `x-fern-parameter-name`, - // surface the original wire name in `--help` so users can - // still correlate the flag with the API doc / `--params` JSON. - // (Synthetic `flag_name_override` injections already encode - // the wire name in their description, so they skip this.) - let help_text = match param.display_name.as_deref() { - Some(alias) if param.flag_name_override.is_none() && alias != param_name => { - if help_text.is_empty() { - format!("(wire name: {param_name})") - } else { - format!("{help_text} (wire name: {param_name})") - } + // When the CLI flag differs from the wire name — whether via + // `x-fern-parameter-name` rename or sanitization — surface + // the original wire name in `--help` so users can correlate + // the flag with the API docs / `--params` JSON. Synthetic + // `flag_name_override` injections already encode the wire + // name in their description, so they skip this. + let flag_differs_from_wire = param.flag_name_override.is_none() + && kebab_name != *param_name; + let help_text = if flag_differs_from_wire { + if help_text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{help_text} (api: {param_name})") } - _ => help_text, + } else { + help_text }; // Append the OpenAPI standard `default:` value as a // `[default: ...]` suffix when it is the only default @@ -479,7 +508,8 @@ fn build_resource_command( None => help_text, }; - let mut arg = Arg::new(param_name.clone()) + let arg_id = param_clap_arg_id(param_name); + let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) .help(help_text); @@ -534,6 +564,54 @@ fn build_resource_command( } } +/// Compute the clap arg ID for a parameter given its wire name. +/// +/// Normally the arg ID equals the wire name so the executor can look +/// values up by wire name directly. When the wire name itself collides +/// with a built-in flag's arg ID (e.g. `format`, `output`, `json`), we +/// suffix it with `-param` to avoid a clap duplicate-arg-ID panic. +/// +/// This function is also called by `collect_params_from_flags` so the +/// executor uses the same mangled ID that the command builder registered. +pub(crate) fn param_clap_arg_id(wire_name: &str) -> String { + if BUILTIN_FLAG_NAMES.contains(&wire_name) { + format!("{wire_name}-param") + } else { + wire_name.to_string() + } +} + +/// Resolve the CLI flag name for a parameter, replicating every step that +/// `build_resource_command` applies: override -> body-kebab vs +/// non-body-sanitize -> builtin-collision `-param` suffix. Both the +/// command builder and the executor's missing-param hint must agree on +/// what flag a parameter maps to — this shared helper is the single +/// source of truth. +/// +/// Returns `None` only when `sanitize_flag_name` rejects the name +/// (control characters, CJK, etc.). The caller should fall back to +/// `--params` guidance in that case. +pub(crate) fn resolve_param_flag_name(param: &MethodParameter, wire_name: &str) -> Option { + let mut flag = if let Some(override_flag) = param.flag_name_override.as_deref() { + override_flag.to_string() + } else { + let is_body = param.location.as_deref() == Some("body"); + let source = param.display_name.as_deref().unwrap_or(wire_name); + if is_body { + to_kebab_flag(source) + } else { + match sanitize_flag_name(source) { + Ok(name) => name, + Err(_) => return None, + } + } + }; + if BUILTIN_FLAG_NAMES.contains(&flag.as_str()) { + flag = format!("{flag}-param"); + } + Some(flag) +} + /// Build a `PossibleValuesParser` that respects an optional `x-fern-enum` /// override. When the parameter has no `fern_enum` map, this is a plain /// `PossibleValuesParser::new(wire_values)`. When it does, each wire value @@ -544,7 +622,7 @@ fn build_enum_value_parser( wire_values: &[String], param: &MethodParameter, ) -> PossibleValuesParser { - let possible: Vec = wire_values + let mut possible: Vec = wire_values .iter() .map(|wire| { let cfg = param @@ -554,6 +632,12 @@ fn build_enum_value_parser( build_possible_value(wire, cfg) }) .collect(); + // Null sentinel: when the param is nullable, accept `null` as a + // fourth (etc.) possible value so clap admits it. The conversion to + // `Value::Null` happens later in `collect_params_from_flags`. + if param.nullable { + possible.push(PossibleValue::new("null").help("Send JSON null.")); + } PossibleValuesParser::from(possible) } @@ -573,6 +657,36 @@ fn build_possible_value(wire: &str, cfg: Option<&FernEnumValue>) -> PossibleValu pv } +/// Build a `clap::Arg` for a single [`MultipartField`]. File fields +/// accept a path (or `@path` / `-` for stdin); text fields accept a +/// plain string value. +fn build_multipart_field_arg(field: &MultipartField) -> Arg { + let kebab = to_kebab_flag(&field.wire_name); + let (value_name, help_prefix) = if field.is_file { + ("PATH|@PATH|-", "File to upload") + } else { + ("VALUE", "") + }; + + let help_text = match (&field.description, help_prefix) { + (Some(desc), "") => desc.clone(), + (Some(desc), prefix) => format!("{prefix}. {desc}"), + (None, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + }; + + let mut arg = Arg::new(field.wire_name.clone()) + .long(kebab) + .value_name(value_name) + .help(help_text); + + if field.required { + arg = arg.required(true); + } + + arg +} + #[cfg(test)] mod tests { use super::*; @@ -778,7 +892,91 @@ mod tests { } #[test] - fn test_builtin_flag_names_not_duplicated() { + fn test_nullable_scalar_renders_value_name_with_null_suffix() { + // A nullable scalar body param renders its value_name as `|null` + // so users discover the null sentinel from `--help`. + let mut params = HashMap::new(); + params.insert( + "userId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + nullable: true, + ..Default::default() + }, + ); + params.insert( + "count".to_string(), + MethodParameter { + param_type: Some("integer".to_string()), + location: Some("body".to_string()), + nullable: true, + ..Default::default() + }, + ); + params.insert( + "code".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + nullable: false, + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "create".to_string(), + RestMethod { + http_method: "POST".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let create = cmd + .find_subcommand("things") + .unwrap() + .find_subcommand("create") + .unwrap(); + + let value_name_for = |id: &str| -> String { + let arg = create + .get_arguments() + .find(|a| a.get_id().as_str() == id) + .unwrap_or_else(|| panic!("arg '{id}' missing")); + arg.get_value_names() + .unwrap_or(&[]) + .iter() + .map(|s| s.to_string()) + .collect::>() + .join(",") + }; + + assert_eq!(value_name_for("userId"), "STRING|null"); + assert_eq!(value_name_for("count"), "NUMBER|null"); + assert_eq!( + value_name_for("code"), + "STRING", + "non-nullable scalar must NOT gain the |null suffix", + ); + } + + #[test] + fn test_builtin_flag_names_renamed_with_param_suffix() { let mut params = HashMap::new(); params.insert( "format".to_string(), @@ -831,7 +1029,6 @@ mod tests { ..Default::default() }; - // This should not panic from duplicate arg names let cmd = build_cli(&doc); let things_cmd = cmd .find_subcommand("things") @@ -845,20 +1042,359 @@ mod tests { .map(|a| a.get_id().to_string()) .collect(); - // "format" and "output" should NOT appear as duplicated param flags - // but "real_param" should be present assert!( args.contains(&"real_param".to_string()), "real_param flag missing" ); - // Count occurrences of "format" — should be at most 1 (from the global flag) - let format_count = args.iter().filter(|a| *a == "format").count(); + // Wire names that collide with builtins get a `-param` suffix on + // both the arg ID and the long flag (FER-10430). assert!( - format_count <= 1, - "format flag duplicated: found {format_count}" + args.contains(&"format-param".to_string()), + "format should be renamed to format-param, got: {args:?}" + ); + assert!( + args.contains(&"output-param".to_string()), + "output should be renamed to output-param, got: {args:?}" + ); + + // The long flags should also be suffixed. + let format_arg = test_cmd + .get_arguments() + .find(|a| a.get_id() == "format-param") + .expect("format-param arg missing"); + assert_eq!( + format_arg.get_long().unwrap(), + "format-param", + "format param should have --format-param long flag", + ); + } + + #[test] + fn test_sanitized_param_name_produces_correct_flag() { + let mut params = HashMap::new(); + params.insert( + "id:in".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + params.insert( + "date_created:min".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Min date".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let list_cmd = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .expect("things list missing"); + + // The arg IDs are the wire names (no builtin collision). + let arg_ids: Vec = list_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!( + arg_ids.contains(&"id:in".to_string()), + "arg ID should be the wire name 'id:in', got: {arg_ids:?}", + ); + assert!( + arg_ids.contains(&"date_created:min".to_string()), + "arg ID should be the wire name 'date_created:min', got: {arg_ids:?}", + ); + + // But the long flags are sanitized. + let id_in = list_cmd + .get_arguments() + .find(|a| a.get_id() == "id:in") + .unwrap(); + assert_eq!(id_in.get_long().unwrap(), "id-in"); + + let date_min = list_cmd + .get_arguments() + .find(|a| a.get_id() == "date_created:min") + .unwrap(); + assert_eq!(date_min.get_long().unwrap(), "date-created-min"); + } + + #[test] + fn test_sanitized_flag_help_shows_wire_name() { + let mut params = HashMap::new(); + params.insert( + "id:in".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let list_cmd = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .unwrap(); + let id_in = list_cmd + .get_arguments() + .find(|a| a.get_id() == "id:in") + .unwrap(); + let help = id_in.get_help().unwrap().to_string(); + assert!( + help.contains("api: id:in"), + "help text should include the wire name; got: {help}", + ); + } + #[test] + fn test_variable_bound_param_does_not_block_same_named_normal_param() { + // Finding 1: a variable-bound param (e.g. `projectId`) that + // sanitizes to the same flag as a normal param (e.g. + // `project_id` -> `project-id`) must not occupy a collision + // slot and prevent the normal param from getting a flag. + let mut params = HashMap::new(); + params.insert( + "projectId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("path".to_string()), + variable_reference: Some("projectId".to_string()), + ..Default::default() + }, + ); + params.insert( + "project_id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by project".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/projects/{projectId}/items".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "items".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let list_cmd = cmd + .find_subcommand("items") + .and_then(|c| c.find_subcommand("list")) + .expect("items list missing"); + + let arg_ids: Vec = list_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + + // The normal query param `project_id` should be registered + // even though `projectId` (variable-bound) sanitizes to the + // same flag name `project-id`. + assert!( + arg_ids.contains(&"project_id".to_string()), + "project_id flag should be registered despite variable-bound projectId; got: {arg_ids:?}", + ); + + // The variable-bound param should NOT have a per-op flag. + assert!( + !arg_ids.contains(&"projectId".to_string()), + "variable-bound projectId should not appear as a per-op flag; got: {arg_ids:?}", + ); + + // Verify the long flag is correct. + let proj_arg = list_cmd + .get_arguments() + .find(|a| a.get_id() == "project_id") + .unwrap(); + assert_eq!(proj_arg.get_long().unwrap(), "project-id"); + } + + #[test] + fn test_resolve_param_flag_name_body_preserves_dots() { + // Finding 2: body params use to_kebab_flag which preserves + // dot-notation; the helper must replicate this. + let param = MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + ..Default::default() + }; + let flag = resolve_param_flag_name(¶m, "address.street").unwrap(); + assert_eq!( + flag, "address.street", + "body param dots should be preserved via to_kebab_flag", ); } + + #[test] + fn test_resolve_param_flag_name_builtin_collision() { + // Finding 2: params colliding with builtins get `-param` suffix. + let param = MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + ..Default::default() + }; + let flag = resolve_param_flag_name(¶m, "format").unwrap(); + assert_eq!( + flag, "format-param", + "builtin collision should append -param", + ); + } + + #[test] + fn test_resolve_param_flag_name_sanitizes_non_body() { + let param = MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + ..Default::default() + }; + let flag = resolve_param_flag_name(¶m, "status:in").unwrap(); + assert_eq!(flag, "status-in"); + } + + #[test] + fn test_resolve_param_flag_name_uses_override() { + let param = MethodParameter { + flag_name_override: Some("custom-flag".to_string()), + location: Some("header".to_string()), + ..Default::default() + }; + let flag = resolve_param_flag_name(¶m, "X-Custom-Header").unwrap(); + assert_eq!(flag, "custom-flag"); + } + + #[test] + fn test_resolve_param_flag_name_uses_display_name() { + let param = MethodParameter { + display_name: Some("searchQuery".to_string()), + location: Some("query".to_string()), + ..Default::default() + }; + let flag = resolve_param_flag_name(¶m, "filter_term").unwrap(); + assert_eq!(flag, "search-query"); + } + + #[test] + fn test_resolve_param_flag_name_is_idempotent() { + // Applying the helper twice must equal applying it once. The + // function calls sanitize_flag_name (idempotent) and may append + // `-param`. Since `format-param` is NOT in BUILTIN_FLAG_NAMES, + // a second application stays `format-param`. + + // Case 1: body param with dot-notation (`address.street`). + let body_param = MethodParameter { + location: Some("body".to_string()), + ..Default::default() + }; + let once = resolve_param_flag_name(&body_param, "address.street").unwrap(); + let twice = resolve_param_flag_name(&body_param, &once).unwrap(); + assert_eq!(once, twice, "body dot-notation must be idempotent"); + + // Case 2: flag_name_override — the override is used verbatim. + let override_param = MethodParameter { + flag_name_override: Some("custom-flag".to_string()), + location: Some("header".to_string()), + ..Default::default() + }; + let once = resolve_param_flag_name(&override_param, "X-Custom-Header").unwrap(); + let twice = resolve_param_flag_name(&override_param, &once).unwrap(); + assert_eq!(once, twice, "override case must be idempotent"); + + // Case 3: sanitize case (`id:in` → `id-in`). + let query_param = MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }; + let once = resolve_param_flag_name(&query_param, "id:in").unwrap(); + let twice = resolve_param_flag_name(&query_param, &once).unwrap(); + assert_eq!(once, twice, "sanitize case must be idempotent"); + + // Case 4: builtin-collision case (`format` → `format-param`). + let collision_param = MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }; + let once = resolve_param_flag_name(&collision_param, "format").unwrap(); + assert_eq!(once, "format-param", "sanity: first application appends -param"); + let twice = resolve_param_flag_name(&collision_param, &once).unwrap(); + assert_eq!(once, twice, "builtin-collision case must be idempotent"); + } + // ------------------------------------------------------------------ // x-fern-enum → clap PossibleValue wiring // @@ -1016,6 +1552,69 @@ mod tests { assert_eq!(matches.get_one::("status").unwrap(), "managed"); } + #[test] + fn test_build_enum_value_parser_accepts_null_when_param_nullable() { + // A nullable enum field must accept the literal `null` alongside its + // wire values. The sentinel→Value::Null transform happens later in + // collect_params_from_flags; clap's job is just to admit the string. + let param = MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(vec!["red".to_string(), "blue".to_string()]), + nullable: true, + ..Default::default() + }; + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), ¶m); + let cmd = Command::new("test").arg(Arg::new("color").long("color").value_parser(parser)); + + for input in ["red", "blue", "null"] { + cmd.clone() + .try_get_matches_from(vec!["test", "--color", input]) + .unwrap_or_else(|e| panic!("nullable enum should accept `{input}`; got: {e}")); + } + + assert!( + cmd.clone() + .try_get_matches_from(vec!["test", "--color", "purple"]) + .is_err(), + "values outside the enum (and not the null sentinel) must still be rejected", + ); + } + + #[test] + fn test_build_enum_value_parser_rejects_null_when_non_nullable() { + // Regression guard: a non-nullable enum field must NOT accept "null". + let param = MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(vec!["red".to_string(), "blue".to_string()]), + nullable: false, + ..Default::default() + }; + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), ¶m); + let cmd = Command::new("test").arg(Arg::new("color").long("color").value_parser(parser)); + assert!( + cmd.try_get_matches_from(vec!["test", "--color", "null"]) + .is_err(), + "non-nullable enum must reject `null` (closed set)", + ); + } + + #[test] + fn test_build_enum_value_parser_nullable_lists_null_in_help() { + // The clap-rendered help must include `null` in the [possible values] + // listing so users can discover the sentinel from `--help` alone. + let param = MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(vec!["red".to_string(), "blue".to_string()]), + nullable: true, + ..Default::default() + }; + let help = render_arg_long_help(¶m); + assert!( + help.contains("null"), + "nullable enum's long help must list `null` as a possible value, got:\n{help}", + ); + } + #[test] fn test_json_help_text_rest_method() { use crate::openapi::discovery::SchemaRef; @@ -1560,4 +2159,88 @@ paths: }; assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + + #[test] + fn test_multipart_field_builtin_collision_skipped() { + use crate::openapi::discovery::MultipartField; + + let mut methods = HashMap::new(); + methods.insert( + "upload".to_string(), + RestMethod { + http_method: "POST".to_string(), + path: "/uploads".to_string(), + multipart_fields: vec![ + MultipartField { + wire_name: "format".to_string(), + is_file: false, + description: Some("Collides with builtin --format".to_string()), + required: false, + content_type: None, + }, + MultipartField { + wire_name: "output".to_string(), + is_file: false, + description: Some("Collides with builtin --output".to_string()), + required: false, + content_type: None, + }, + MultipartField { + wire_name: "file".to_string(), + is_file: true, + description: Some("No collision".to_string()), + required: true, + content_type: Some("application/octet-stream".to_string()), + }, + ], + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "uploads".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + // Must not panic from duplicate arg names. + let cmd = build_cli(&doc); + let uploads_cmd = cmd + .find_subcommand("uploads") + .expect("uploads resource missing"); + let upload_cmd = uploads_cmd + .find_subcommand("upload") + .expect("upload method missing"); + + let arg_ids: Vec = upload_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + + // "file" should be present (no collision). + assert!( + arg_ids.contains(&"file".to_string()), + "non-colliding multipart field 'file' should be present, got: {arg_ids:?}", + ); + // "format" and "output" appear exactly once (from the global builtins). + let format_count = arg_ids.iter().filter(|a| *a == "format").count(); + assert!( + format_count <= 1, + "multipart 'format' should be skipped to avoid duplicate; found {format_count} in {arg_ids:?}", + ); + let output_count = arg_ids.iter().filter(|a| *a == "output").count(); + assert!( + output_count <= 1, + "multipart 'output' should be skipped to avoid duplicate; found {output_count} in {arg_ids:?}", + ); + } } diff --git a/generators/cli/sdk/src/openapi/discovery.rs b/generators/cli/sdk/src/openapi/discovery.rs index 3f67f8a2228a..c93a439a40ef 100644 --- a/generators/cli/sdk/src/openapi/discovery.rs +++ b/generators/cli/sdk/src/openapi/discovery.rs @@ -433,23 +433,22 @@ impl RestDescription { /// Default total attempts (initial + retries) when retries are enabled. /// -/// CLI users typically don't expect retries by default — they want fast, -/// observable failures — so we ship a *conservative* default that retries -/// at most once. The spec author can override this with +/// 4 total attempts = 3 retries. Matches the fern Python/TypeScript +/// runtime SDKs. The spec author can override this with /// `x-fern-retries: { max_attempts: N }`. /// -/// This is deliberately lower than the fern Python/TypeScript runtime SDKs -/// (which default to 3 total attempts) because those SDKs are embedded in +/// This was raised from 2 (FER-10521) to align with the cross-SDK +/// default and the expectation that transient 5xx / 429 / network +/// failures benefit from multiple retry attempts. CLIs that are embedded in /// long-running applications where the latency of an extra retry is /// acceptable. The CLI is interactive — a 3-second backoff before the /// final failure feels broken. -pub const DEFAULT_RETRY_MAX_ATTEMPTS: u32 = 2; +pub const DEFAULT_RETRY_MAX_ATTEMPTS: u32 = 4; /// Default exponential-backoff base delay in milliseconds. The wait before /// retry N is `base * factor^N` (plus jitter). With -/// [`DEFAULT_RETRY_MAX_ATTEMPTS`] = 2 the single retry happens after -/// `base * factor^0` = 250ms. -pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 250; +/// [`DEFAULT_RETRY_MAX_ATTEMPTS`] = 4 the delays are 500ms, 1s, 2s. +pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 500; /// Default exponential-backoff growth factor. pub const DEFAULT_RETRY_FACTOR: f64 = 2.0; @@ -572,6 +571,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// Fields for a `multipart/form-data` request body. When non-empty, the + /// executor sends the request as multipart instead of JSON, and each + /// field surfaces as a per-operation CLI flag. Empty for non-multipart + /// operations. + #[serde(default, skip)] + pub multipart_fields: Vec, /// How the request body should be serialized on the wire. /// /// Defaults to `BodyEncoding::Json`. The executor reads this to decide @@ -645,6 +650,11 @@ pub struct RestMethod { /// stream. #[serde(default, skip)] pub streaming: Option, + /// When `true`, the executor does NOT auto-generate an + /// `Idempotency-Key` header for this operation even though it uses + /// POST/PUT/PATCH. Set from `x-fern-cli-idempotency: false`. + #[serde(default, skip)] + pub no_auto_idempotency_key: bool, /// Resolved `x-fern-retries` extension for this operation, after /// applying root-level inheritance (per-op `true` adopts the spec-root /// baseline; per-op object merges field-by-field over root). `None` @@ -827,6 +837,29 @@ pub struct BinaryRequestBody { pub flag_name: String, } +/// A single field in a `multipart/form-data` request body. Each field +/// becomes a CLI flag whose value is sent as one part in the multipart +/// body. File-typed fields accept a filesystem path (or `@path` / +/// `-` for stdin) and are streamed as binary parts with the appropriate +/// content type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MultipartField { + /// Wire name sent as the `name` in `Content-Disposition: form-data; name="..."`. + pub wire_name: String, + /// `true` when the field's schema is `type: string, format: binary` + /// (or `type: file`). File fields accept a path and stream the + /// contents; text fields send the flag value as a UTF-8 text part. + pub is_file: bool, + /// Human-readable description from the spec (surfaces in `--help`). + pub description: Option, + /// Whether the spec marks this field as required. + pub required: bool, + /// Content type hint for file parts (e.g. `application/octet-stream`). + /// Only meaningful when `is_file` is true; text parts always use + /// `text/plain; charset=utf-8`. + pub content_type: Option, +} + /// Media upload metadata. #[derive(Debug, Clone, Deserialize, Default)] pub struct MediaUpload { @@ -914,6 +947,15 @@ pub struct MethodParameter { /// implicit default (no badge). #[serde(default)] pub availability: Option, + /// True when this body parameter's schema admits JSON `null` as a valid + /// value (OpenAPI 3.0 `nullable: true` or 3.1 `type: [..., "null"]`). + /// Gated on scalar `param_type` (`string` / `integer` / `number` / + /// `boolean`) — composite types stay false because the null sentinel + /// surface is scalar-only (see ADR-0003). When true, the CLI accepts + /// the literal `null` as a flag value and converts it to `Value::Null` + /// at request-build time. + #[serde(default)] + pub nullable: bool, /// Optional environment variable that supplies a default value when /// the corresponding CLI flag is not passed. Populated for synthetic /// parameters injected by Fern extensions (e.g. idempotency headers); diff --git a/generators/cli/sdk/src/openapi/executor.rs b/generators/cli/sdk/src/openapi/executor.rs index 2af619a5c605..482460d1e085 100644 --- a/generators/cli/sdk/src/openapi/executor.rs +++ b/generators/cli/sdk/src/openapi/executor.rs @@ -69,6 +69,28 @@ pub enum UploadSource<'a> { }, } +/// A single part in a multipart/form-data request, collected from CLI +/// flags for operations that declare `multipart/form-data` bodies. +pub enum MultipartPart { + /// UTF-8 text value sent as a plain form field. `content_type` carries + /// an explicit per-part `Content-Type` from the OpenAPI `encoding` + /// object; `None` means reqwest's default (`text/plain`). + Text { + name: String, + value: String, + content_type: Option, + }, + /// File read from disk (or stdin). The `path` is already validated. + /// `content_type` is the per-part `Content-Type` resolved from the + /// OpenAPI `encoding` object (falling back to the schema-inferred + /// `application/octet-stream` for file fields). + File { + name: String, + path: String, + content_type: Option, + }, +} + /// Configuration for auto-pagination. #[derive(Debug, Clone)] pub struct PaginationConfig { @@ -111,27 +133,18 @@ pub(crate) struct RetryOutcome<'a> { pub retry_after: Option<&'a str>, } -/// Returns the default set of retryable HTTP status codes. -/// -/// Matches fern's TypeScript SDK `retryStatusCodes: recommended` mode -/// ([fern PR](https://github.com/fern-api/fern/blob/main/generators/typescript/sdk/changes/3.67.0/feat-retry-status-codes.yml)): +/// Returns `true` when the HTTP status code is considered retryable. /// +/// Retryable statuses (FER-10521): /// - 408 Request Timeout — server gave up before reading; safe. /// - 429 Too Many Requests — backoff signal; safe. -/// - 502 Bad Gateway — upstream layer failed; transient. -/// - 503 Service Unavailable — explicitly transient by spec. -/// - 504 Gateway Timeout — upstream timeout; transient. +/// - 500–599 (all server errors) — transient infrastructure failures. /// -/// Deliberately *excludes* 500 Internal Server Error — a 500 often -/// indicates a non-transient bug on the server (bad input shape, app -/// crash) where retrying just masks the underlying issue. Servers that -/// genuinely want us to retry a 500 can still surface a `Retry-After` -/// header and the executor will honor it. -/// -/// Also excludes 425 Too Early (TLS 1.3 0-RTT replay protection) — -/// never seen in practice from reqwest's HTTP/1.1 client. +/// Prior to FER-10521 this excluded 500 (often a non-transient bug). +/// The broader 5xx set aligns with the cross-SDK default and with +/// the expectation that CLIs retry aggressively on server-side errors. pub(crate) fn is_retryable_status(status: u16) -> bool { - matches!(status, 408 | 429 | 502 | 503 | 504) + status == 408 || status == 429 || (500..=599).contains(&status) } /// Whether the per-method retry policy allows retrying *non-idempotent* @@ -161,6 +174,22 @@ pub(crate) fn binary_body_is_stdin(binary_body_path: Option<&str>) -> bool { } } +/// Whether any `MultipartPart::File` in the list uses stdin (`-` or `@-`). +/// Stdin-sourced file parts cannot be replayed on retry because the pipe is +/// consumed by the first `read_to_end`. Mirrors `binary_body_is_stdin`. +pub(crate) fn multipart_has_stdin(parts: &Option>) -> bool { + match parts { + Some(parts) => parts.iter().any(|p| match p { + MultipartPart::File { path, .. } => { + let stripped = path.strip_prefix('@').unwrap_or(path); + stripped == "-" + } + MultipartPart::Text { .. } => false, + }), + None => false, + } +} + /// Parse a `Retry-After` header value into a `Duration`. /// /// HTTP/1.1 allows two forms (RFC 7231 §7.1.3): a non-negative integer @@ -327,22 +356,13 @@ fn parse_and_validate_inputs( Map::new() }; - // Helper: build the `Provide it via …` hint listing every channel a - // user can satisfy this parameter through. Mirrors `commands.rs`'s - // flag-name resolution so the suggested `--` is the actual flag - // the user can pass: `flag_name_override` wins verbatim (synthetic - // injections that already encode the wire name); otherwise kebab the - // `display_name` from `x-fern-parameter-name`, falling back to the - // wire name. Body fields also accept `--json`; every other location - // only accepts the per-field flag or `--params`. + // Helper: build the `Provide it via …` hint. Uses the same + // `resolve_param_flag_name` that the command builder uses so the + // suggested `--` matches the actually registered flag + // (including body-param dot-notation and `-param` builtin suffix). let missing_param_hint = |param_def: &MethodParameter, param_name: &str| -> String { - let flag = if let Some(override_flag) = param_def.flag_name_override.as_deref() { - override_flag.to_string() - } else { - crate::text::to_kebab_flag( - param_def.display_name.as_deref().unwrap_or(param_name), - ) - }; + let flag = crate::openapi::commands::resolve_param_flag_name(param_def, param_name) + .unwrap_or_else(|| crate::text::to_kebab_flag(param_name)); if param_def.location.as_deref() == Some("body") { format!("Provide it via --{flag}, --json, or --params") } else { @@ -389,10 +409,8 @@ fn parse_and_validate_inputs( let location = method.parameters.get(key).and_then(|p| p.location.as_deref()); match location { Some("header") => { - let str_value = match value { - Value::String(s) => s.clone(), - other => other.to_string(), - }; + let param_def = method.parameters.get(key); + let str_value = serialize_header_simple(value, param_def)?; header_params.push((key.clone(), str_value)); } Some("body") => { @@ -575,19 +593,46 @@ async fn build_http_request( pages_fetched: u32, upload: &Option>, binary_body_path: Option<&str>, + multipart_parts: &Option>, pagination: &PaginationConfig, ) -> Result { // Uri / Path pagination supplies a fully-resolved next URL in the // page state; use it verbatim so that the server's cursor / query // params travel as-is. - let target_url = page_state.url_override().unwrap_or(&input.full_url); + let base_target_url = page_state.url_override().unwrap_or(&input.full_url); + + // Build the query string ourselves (rather than reqwest's `.query()`, + // which form-encodes: space -> `+`, comma -> `%2C`). The OpenAPI 3.0 + // query styles need RFC 3986 percent-encoding with style delimiters left + // literal in the joined value (spaceDelimited -> `%20`, pipeDelimited -> + // `%7C`, form/no-explode arrays keep `,`). When the URL is supplied by the + // server (uri / path pagination) it already carries every query param the + // server cares about, so we honor it as-is. + let target_url = if page_state.url_override().is_some() { + base_target_url.to_string() + } else { + let mut all_query_params = input.query_params.clone(); + if let Some((name, value)) = + page_state.injection(method.pagination.as_ref(), &pagination.token_query_param) + { + all_query_params.push((name, value)); + } + // Upload operations carry `uploadType=multipart`; route it through the + // same RFC-3986 query encoder as every other param instead of reqwest's + // `.query()` (the value is an ASCII literal, so the encoded form is + // identical — this just keeps a single query encoder). + if pages_fetched == 0 && upload.is_some() { + all_query_params.push(("uploadType".to_string(), "multipart".to_string())); + } + append_query_string(base_target_url, &all_query_params) + }; let mut request = match method.http_method.as_str() { - "GET" => client.get(target_url), - "POST" => client.post(target_url), - "PUT" => client.put(target_url), - "PATCH" => client.patch(target_url), - "DELETE" => client.delete(target_url), + "GET" => client.get(&target_url), + "POST" => client.post(&target_url), + "PUT" => client.put(&target_url), + "PATCH" => client.patch(&target_url), + "DELETE" => client.delete(&target_url), other => { return Err(CliError::Other(anyhow::anyhow!( "Unsupported HTTP method: {other}" @@ -624,25 +669,8 @@ async fn build_http_request( } } - // When the URL is supplied by the server (uri / path pagination) - // the URL already carries every query param the server cares about - // — re-appending the user's initial filters would either double them - // up or fight the server's own cursor. Honor the server's URL as-is. - if page_state.url_override().is_none() { - let mut all_query_params = input.query_params.clone(); - if let Some((name, value)) = - page_state.injection(method.pagination.as_ref(), &pagination.token_query_param) - { - all_query_params.push((name, value)); - } - if !all_query_params.is_empty() { - request = request.query(&all_query_params); - } - } - if pages_fetched == 0 { if let Some(upload_source) = upload { - request = request.query(&[("uploadType", "multipart")]); let (body, content_type, content_length) = match upload_source { UploadSource::Bytes { data, content_type } => { if content_type.contains('\r') || content_type.contains('\n') { @@ -693,6 +721,9 @@ async fn build_http_request( request = request.body(build_stdin_body_stream()); } } + } else if let Some(parts) = multipart_parts { + let form = build_multipart_form(parts).await?; + request = request.multipart(form); } else if let Some(ref body_val) = input.body { request = encode_request_body(request, body_val, &method.body_encoding); } else if matches!(method.http_method.as_str(), "POST" | "PUT" | "PATCH") { @@ -1463,6 +1494,7 @@ pub async fn execute_method( output_path: Option<&str>, upload: Option>, binary_body_path: Option<&str>, + multipart_parts: Option>, dry_run: bool, pagination: &PaginationConfig, pipeline: &crate::formatter::OutputPipeline, @@ -1547,6 +1579,28 @@ pub async fn execute_method( "flag": flag_name, }); } + if let Some(ref parts) = multipart_parts { + let part_info: Vec = parts + .iter() + .map(|p| match p { + MultipartPart::Text { + name, + value, + content_type, + } => { + json!({ "name": name, "type": "text", "value": value, "content_type": content_type }) + } + MultipartPart::File { + name, + path, + content_type, + } => { + json!({ "name": name, "type": "file", "path": path, "content_type": content_type }) + } + }) + .collect(); + dry_run_info["multipart_form_data"] = json!(part_info); + } if capture_output { return Ok(Some(dry_run_info)); } @@ -1591,14 +1645,38 @@ pub async fn execute_method( // an empty body. Disable retries for that case so we preserve // the pre-retry behavior (a single attempt, surface whatever // the server returns) rather than masking the original failure. - let retries_cfg = if binary_body_is_stdin(binary_body_path) { - None + // Disable retries when the body is a streamed stdin or multipart + // body — those can't be replayed on a second attempt. + let default_retries = RetriesConfig::default(); + let retries_cfg = + if binary_body_is_stdin(binary_body_path) || multipart_has_stdin(&multipart_parts) { + None + } else { + Some(method.retries.as_ref().unwrap_or(&default_retries)) + }; + + // Auto Idempotency-Key: generate once before the retry loop so + // the same key is sent on every attempt. Only for POST/PUT/PATCH + // unless opted out via `x-fern-cli-idempotency: false`, or when + // the operation already has an explicit idempotency-header + // mechanism (x-fern-idempotent: true provides --idempotency-key). + let user_provides_idempotency = method.idempotent + || input + .header_params + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("idempotency-key")); + let idempotency_key = if !method.no_auto_idempotency_key + && !user_provides_idempotency + && crate::http::needs_idempotency_key(&method.http_method) + { + Some(crate::http::generate_idempotency_key()) } else { - method.retries.as_ref() + None }; + let mut retry_attempt: u32 = 0; let response = loop { - let request = build_http_request( + let mut request = build_http_request( &client, method, &input, @@ -1608,10 +1686,15 @@ pub async fn execute_method( pages_fetched, &upload, binary_body_path, + &multipart_parts, pagination, ) .await?; + if let Some(ref key) = idempotency_key { + request = request.header("Idempotency-Key", key.as_str()); + } + match request.send().await { Ok(resp) => { let status = resp.status(); @@ -1630,7 +1713,7 @@ pub async fn execute_method( &outcome, cfg, &method.http_method, - method.idempotent, + method.idempotent || idempotency_key.is_some(), no_retry, ) { tracing::warn!( @@ -1665,7 +1748,7 @@ pub async fn execute_method( &outcome, cfg, &method.http_method, - method.idempotent, + method.idempotent || idempotency_key.is_some(), no_retry, ) { tracing::warn!( @@ -1856,10 +1939,33 @@ fn serialize_query_param( match style { "deepObject" => serialize_deep_object(key, value), + // spaceDelimited / pipeDelimited only define array behavior; the + // elements are joined by a single space / pipe under one key. For + // non-array values they degrade to the same scalar shape as form. + "spaceDelimited" => serialize_delimited(key, value, ' '), + "pipeDelimited" => serialize_delimited(key, value, '|'), _ => serialize_form(key, value, explode), } } +/// `spaceDelimited` / `pipeDelimited` array serialization: a single key whose +/// value is the elements joined by `delim`. The delimiter is a literal here; +/// the request encoder percent-encodes it on the wire (space -> `%20`, +/// pipe -> `%7C`). +fn serialize_delimited(key: &str, value: &Value, delim: char) -> Vec<(String, String)> { + match value { + Value::Array(arr) => { + let joined = arr + .iter() + .map(value_to_query_string) + .collect::>() + .join(&delim.to_string()); + vec![(key.to_string(), joined)] + } + _ => vec![(key.to_string(), value_to_query_string(value))], + } +} + fn serialize_deep_object(key: &str, value: &Value) -> Vec<(String, String)> { match value { Value::Object(_) => { @@ -1907,6 +2013,23 @@ fn serialize_form(key: &str, value: &Value, explode: bool) -> Vec<(String, Strin .join(","); vec![(key.to_string(), joined)] } + // form / object / explode=true: each property becomes its own + // top-level key (`role=admin&active=true`), dropping the parameter + // name entirely — the OpenAPI 3.0 rule for an exploded object. + Value::Object(map) if explode => map + .iter() + .map(|(k, v)| (k.clone(), value_to_query_string(v))) + .collect(), + // form / object / explode=false: comma-joined `key,value` pairs under + // the single parameter key (`profile=role,admin,active,true`). + Value::Object(map) => { + let joined = map + .iter() + .flat_map(|(k, v)| [k.clone(), value_to_query_string(v)]) + .collect::>() + .join(","); + vec![(key.to_string(), joined)] + } _ => vec![(key.to_string(), value_to_query_string(value))], } } @@ -1921,6 +2044,86 @@ fn value_to_query_string(v: &Value) -> String { } } +/// Serialize a header parameter value into its OpenAPI `simple`-style wire +/// representation (the only style permitted for `in: header` parameters). +/// +/// - primitive → the scalar rendered as-is (`X-Custom: hello`) +/// - array → elements comma-joined under one name; `explode` does not change +/// the delimiter for `simple` (`X-Tags: a,b`) +/// - object, `explode: false` → flattened `k,v,k2,v2` (`X-Filter: k,v,k2,v2`) +/// - object, `explode: true` → `k=v,k2=v2` +/// +/// The fully-assembled value is rejected if it contains control characters, +/// which would otherwise enable header injection (CR/LF) when the value +/// arrives from an untrusted CLI argument. +fn serialize_header_simple( + value: &Value, + param_def: Option<&crate::openapi::discovery::MethodParameter>, +) -> Result { + let explode = param_def.and_then(|p| p.explode).unwrap_or(false); + + let rendered = match value { + Value::Array(arr) => arr + .iter() + .map(value_to_query_string) + .collect::>() + .join(","), + Value::Object(map) => map + .iter() + .flat_map(|(k, v)| { + let v = value_to_query_string(v); + if explode { + vec![format!("{k}={v}")] + } else { + vec![k.clone(), v] + } + }) + .collect::>() + .join(","), + other => value_to_query_string(other), + }; + + crate::output::reject_dangerous_chars(&rendered, "header value")?; + Ok(rendered) +} + +/// Percent-encode set for a query-string component (key or value). +/// +/// RFC 3986 unreserved characters (`A-Za-z0-9-_.~`) are left intact; the comma +/// is also kept literal so a form/no-explode array reads `ids=1,2` rather than +/// `ids=1%2C2`. Everything else — including space (`%20`, *not* the form +/// `+`), `|` (`%7C`), `&`, `=`, `#`, and `[` `]` — is percent-encoded. This is +/// the RFC 3986 encoding the OpenAPI 3.0 query styles expect, and is stricter +/// than reqwest's `serde_urlencoded`-based `.query()` form encoding. +const QUERY_COMPONENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~') + .remove(b','); + +fn encode_query_component(s: &str) -> String { + percent_encoding::utf8_percent_encode(s, QUERY_COMPONENT).to_string() +} + +/// Append already-style-serialized `(key, value)` query pairs to `base_url`, +/// percent-encoding each component per [`QUERY_COMPONENT`]. Pairs are joined +/// with `&`; the leading separator is `?` unless `base_url` already carries a +/// query string, in which case `&` continues it. Returns `base_url` unchanged +/// when there are no pairs. +fn append_query_string(base_url: &str, pairs: &[(String, String)]) -> String { + if pairs.is_empty() { + return base_url.to_string(); + } + let query = pairs + .iter() + .map(|(k, v)| format!("{}={}", encode_query_component(k), encode_query_component(v))) + .collect::>() + .join("&"); + let sep = if base_url.contains('?') { '&' } else { '?' }; + format!("{base_url}{sep}{query}") +} + fn effective_root_url(method: &RestMethod, doc: &RestDescription) -> String { if !method.root_url.is_empty() { method.root_url.clone() } else { doc.root_url.clone() } } @@ -1996,7 +2199,7 @@ fn build_url( let rendered_base_path = doc .base_path .as_deref() - .map(|bp| render_path_template(bp, params)) + .map(|bp| render_path_template(bp, params, Some(&method.parameters))) .transpose()?; let base_path_parameters: HashSet<&str> = doc .base_path @@ -2080,7 +2283,7 @@ fn build_url( query_params.extend(pairs); } - let url_path = render_path_template(path_template, params)?; + let url_path = render_path_template(path_template, params, Some(&method.parameters))?; let full_url = if is_upload { // Use the upload endpoint from the Discovery Document @@ -2096,7 +2299,7 @@ fn build_url( .to_string(), ) })?; - let upload_path = render_path_template(upload_endpoint, params)?; + let upload_path = render_path_template(upload_endpoint, params, Some(&method.parameters))?; // Compose the upload host with the spec-level base_path the same // way the non-upload branch does, so x-fern-base-path is applied // uniformly. This branch is currently unreachable from OpenAPI @@ -2145,6 +2348,7 @@ fn extract_template_path_parameters(path_template: &str) -> HashSet<&str> { fn render_path_template( path_template: &str, params: &Map, + param_defs: Option<&HashMap>, ) -> Result { let mut rendered = String::with_capacity(path_template.len()); let mut cursor = 0; @@ -2167,15 +2371,21 @@ fn render_path_template( }; if let Some(value) = params.get(key) { - let val_str = match value { - Value::String(s) => s.clone(), - other => other.to_string(), - }; let encoded = if is_plus { + // RFC 6570 `{+var}` reserved expansion: preserve literal `/`. + let val_str = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; let validated = crate::validate::validate_resource_name(&val_str)?; crate::validate::encode_path_preserving_slashes(validated) } else { - crate::validate::encode_path_segment(&val_str) + // Consult the parameter's OpenAPI serialization `style` + // (simple / label / matrix) and `explode` flag. Falls back + // to plain simple/primitive substitution when no definition + // is available (e.g. `x-fern-base-path` placeholders). + let param_def = param_defs.and_then(|defs| defs.get(key)); + serialize_path_param(key, value, param_def) }; rendered.push_str(&encoded); } else { @@ -2189,6 +2399,123 @@ fn render_path_template( Ok(rendered) } +/// Serialize a value into a single URL path segment per the OpenAPI 3.0 path +/// `style` (`simple` default, `label`, `matrix`) and `explode` flag. +/// +/// Only the user-supplied *values* are percent-encoded +/// ([`encode_path_segment`](crate::validate::encode_path_segment)); the +/// structural separators introduced by the style (`,`, `.`, `;`, `=`) are +/// literal. Because `encode_path_segment` itself encodes those characters, +/// assembling the segment from already-encoded values keeps the separators +/// from being double-encoded. +fn serialize_path_param( + name: &str, + value: &Value, + param_def: Option<&MethodParameter>, +) -> String { + let style = param_def + .and_then(|p| p.style.as_deref()) + .unwrap_or("simple"); + // OpenAPI default `explode` is false for every path style. + let explode = param_def.and_then(|p| p.explode).unwrap_or(false); + + let enc = |v: &Value| crate::validate::encode_path_segment(&value_to_path_string(v)); + + match style { + "label" => match value { + Value::Array(arr) => { + // RFC 6570: explode=true -> dot-separated; explode=false -> comma-separated. + let joiner = if explode { "." } else { "," }; + let body = arr.iter().map(&enc).collect::>().join(joiner); + format!(".{body}") + } + Value::Object(map) => { + if explode { + // explode=true: k=v pairs dot-separated. + let body = map + .iter() + .map(|(k, v)| { + format!("{}={}", crate::validate::encode_path_segment(k), enc(v)) + }) + .collect::>() + .join("."); + format!(".{body}") + } else { + // explode=false: flat k,v,k,v comma-separated. + let body = map + .iter() + .flat_map(|(k, v)| { + [crate::validate::encode_path_segment(k), enc(v)] + }) + .collect::>() + .join(","); + format!(".{body}") + } + } + _ => format!(".{}", enc(value)), + }, + "matrix" => match value { + Value::Array(arr) if explode => arr + .iter() + .map(|v| format!(";{name}={}", enc(v))) + .collect::>() + .join(""), + Value::Array(arr) => { + let body = arr.iter().map(&enc).collect::>().join(","); + format!(";{name}={body}") + } + Value::Object(map) if explode => map + .iter() + .map(|(k, v)| { + format!(";{}={}", crate::validate::encode_path_segment(k), enc(v)) + }) + .collect::>() + .join(""), + Value::Object(map) => { + let body = map + .iter() + .map(|(k, v)| { + format!("{},{}", crate::validate::encode_path_segment(k), enc(v)) + }) + .collect::>() + .join(","); + format!(";{name}={body}") + } + _ => format!(";{name}={}", enc(value)), + }, + // "simple" (default) and any unrecognized style. + _ => match value { + Value::Array(arr) => arr.iter().map(&enc).collect::>().join(","), + Value::Object(map) if explode => map + .iter() + .map(|(k, v)| { + format!("{}={}", crate::validate::encode_path_segment(k), enc(v)) + }) + .collect::>() + .join(","), + Value::Object(map) => map + .iter() + .flat_map(|(k, v)| [crate::validate::encode_path_segment(k), enc(v)]) + .collect::>() + .join(","), + _ => enc(value), + }, + } +} + +/// Stringify a JSON value for a path segment. Mirrors `value_to_query_string` +/// (the query-side equivalent) — strings pass through, numbers/booleans use +/// their canonical text, null is empty, composites fall back to JSON. +fn value_to_path_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + other => other.to_string(), + } +} + /// Resolves the MIME type for the uploaded media content. /// /// Priority: @@ -2371,6 +2698,89 @@ fn build_multipart_stream( )) } +/// Resolve a file part's `Content-Type`. A per-part value from the OpenAPI +/// `encoding` object wins; otherwise the OAS default for a binary part, +/// `application/octet-stream`, applies. +fn file_part_mime(content_type: Option<&str>) -> &str { + content_type.unwrap_or("application/octet-stream") +} + +/// Build a `reqwest::multipart::Form` from the collected CLI flag values. +/// Text parts are added inline; file parts are read from disk and +/// streamed. The `Content-Type: multipart/form-data; boundary=...` +/// header is set by reqwest automatically when `.multipart(form)` is +/// called on the request builder. +async fn build_multipart_form( + parts: &[MultipartPart], +) -> Result { + let mut form = reqwest::multipart::Form::new(); + + for part in parts { + match part { + MultipartPart::Text { + name, + value, + content_type, + } => { + // A text part is just `Part::text`; an explicit per-part + // `Content-Type` from the OpenAPI `encoding` object (e.g. + // `application/json`) overrides reqwest's `text/plain`. + let mut text_part = reqwest::multipart::Part::text(value.clone()); + if let Some(ct) = content_type { + text_part = text_part.mime_str(ct).map_err(|e| { + CliError::Validation(format!( + "Invalid Content-Type '{ct}' for multipart field '{name}': {e}" + )) + })?; + } + form = form.part(name.clone(), text_part); + } + MultipartPart::File { + name, + path, + content_type, + } => { + let mime = file_part_mime(content_type.as_deref()); + let stripped = path.strip_prefix('@').unwrap_or(path); + let (bytes, file_name) = if stripped == "-" { + let mut buf = Vec::new(); + tokio::io::AsyncReadExt::read_to_end(&mut tokio::io::stdin(), &mut buf) + .await + .map_err(|e| { + CliError::Validation(format!( + "Failed to read stdin for multipart field '{name}': {e}" + )) + })?; + (buf, "stdin".to_string()) + } else { + let file_bytes = tokio::fs::read(stripped).await.map_err(|e| { + CliError::Validation(format!( + "Failed to read file '{stripped}' for multipart field '{name}': {e}" + )) + })?; + let file_name = std::path::Path::new(stripped) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("upload") + .to_string(); + (file_bytes, file_name) + }; + let file_part = reqwest::multipart::Part::bytes(bytes) + .file_name(file_name) + .mime_str(mime) + .map_err(|e| { + CliError::Validation(format!( + "Invalid Content-Type '{mime}' for multipart field '{name}': {e}" + )) + })?; + form = form.part(name.clone(), file_part); + } + } + } + + Ok(form) +} + /// Builds a multipart/related body from in-memory bytes. /// /// Used when the upload content is constructed in memory (e.g., a Gmail RFC 5322 @@ -2662,6 +3072,13 @@ fn validate_property( return; } + // Null on a nullable property is always valid — short-circuits type + // checking that would otherwise reject `null` for a `string` / + // `integer` / etc. base type. + if prop_schema.nullable && value.is_null() { + return; + } + // 2. Type checking if let Some(expected_type) = &prop_schema.prop_type { let type_matches = match (expected_type.as_str(), value) { @@ -2780,16 +3197,13 @@ mod tests { #[test] fn test_is_retryable_status_set_matches_docs() { - // Matches fern TS SDK retryStatusCodes: recommended set: - // 408 / 429 / 502 / 503 / 504. - for s in [408u16, 429, 502, 503, 504] { + // All 5xx plus 408 and 429 are retryable (FER-10521). + for s in [408u16, 429, 500, 501, 502, 503, 504, 505, 599] { assert!(is_retryable_status(s), "{s} should retry"); } - // 500 is deliberately NOT retried \u2014 see is_retryable_status - // docstring. 425 (Too Early), 501 Not Implemented, and other - // 5xx outside the recommended set are terminal. 4xx client - // errors won't change on retry, so they're terminal too. - for s in [200u16, 301, 400, 401, 403, 404, 422, 425, 500, 501, 505] { + // 4xx client errors (except 408/429) won't change on retry \u2014 see is_retryable_status + // and 2xx/3xx are obviously terminal. + for s in [200u16, 301, 400, 401, 403, 404, 422, 425] { assert!(!is_retryable_status(s), "{s} should NOT retry"); } } @@ -2824,6 +3238,147 @@ mod tests { assert!(!binary_body_is_stdin(None)); } + #[test] + fn test_multipart_has_stdin() { + // File part with `-` — retries must be disabled. + assert!(multipart_has_stdin(&Some(vec![MultipartPart::File { + name: "file".into(), + path: "-".into(), + content_type: None, + }]))); + // File part with `@-` — also stdin. + assert!(multipart_has_stdin(&Some(vec![MultipartPart::File { + name: "file".into(), + path: "@-".into(), + content_type: None, + }]))); + // File part with real path — retries are safe. + assert!(!multipart_has_stdin(&Some(vec![MultipartPart::File { + name: "file".into(), + path: "/tmp/upload.bin".into(), + content_type: None, + }]))); + // Text-only parts — retries are safe. + assert!(!multipart_has_stdin(&Some(vec![MultipartPart::Text { + name: "purpose".into(), + value: "test".into(), + content_type: None, + }]))); + // Mixed: one stdin file + one text — still disables retries. + assert!(multipart_has_stdin(&Some(vec![ + MultipartPart::Text { + name: "purpose".into(), + value: "test".into(), + content_type: None, + }, + MultipartPart::File { + name: "file".into(), + path: "-".into(), + content_type: None, + }, + ]))); + // No multipart parts at all. + assert!(!multipart_has_stdin(&None)); + } + + #[test] + fn test_file_part_mime_defaults_and_override() { + // No per-part content type → OAS binary default. + assert_eq!(file_part_mime(None), "application/octet-stream"); + // An encoding-supplied content type wins. + assert_eq!(file_part_mime(Some("image/png")), "image/png"); + assert_eq!(file_part_mime(Some("text/plain")), "text/plain"); + } + + /// Send a built multipart form to a local mock server and return the raw + /// captured body so we can assert the per-part framing the wire carries. + /// reqwest serializes multipart forms as a stream, so the only faithful + /// way to inspect the bytes is to actually transmit them. + async fn multipart_body_string(parts: Vec) -> String { + use wiremock::matchers::method as wm_method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(wm_method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let client = reqwest::Client::new(); + let form = build_multipart_form(&parts).await.unwrap(); + client + .post(format!("{}/upload", server.uri())) + .multipart(form) + .send() + .await + .unwrap(); + + let received = server.received_requests().await.unwrap(); + String::from_utf8_lossy(&received[0].body).into_owned() + } + + #[tokio::test] + async fn test_build_multipart_form_text_part_uses_encoding_content_type() { + // A text part with an explicit encoding contentType emits that + // Content-Type header instead of the default text/plain. + let body = multipart_body_string(vec![MultipartPart::Text { + name: "metadata".into(), + value: "{\"k\":1}".into(), + content_type: Some("application/json".into()), + }]) + .await; + assert!( + body.contains("Content-Disposition: form-data; name=\"metadata\""), + "text part should carry its Content-Disposition; got: {body}" + ); + assert!( + body.contains("Content-Type: application/json"), + "text part Content-Type should come from encoding; got: {body}" + ); + assert!(body.contains("{\"k\":1}"), "value should be in body; got: {body}"); + } + + #[tokio::test] + async fn test_build_multipart_form_file_part_default_octet_stream() { + // A file part without an encoding entry defaults to octet-stream. + let tmp = std::env::temp_dir().join("fern_multipart_default.bin"); + std::fs::write(&tmp, b"payload-bytes").unwrap(); + let body = multipart_body_string(vec![MultipartPart::File { + name: "file".into(), + path: tmp.to_string_lossy().into_owned(), + content_type: None, + }]) + .await; + let _ = std::fs::remove_file(&tmp); + assert!( + body.contains("Content-Disposition: form-data; name=\"file\""), + "file part should carry its Content-Disposition; got: {body}" + ); + assert!( + body.contains("Content-Type: application/octet-stream"), + "file part should default to octet-stream; got: {body}" + ); + assert!(body.contains("payload-bytes"), "file bytes should stream; got: {body}"); + } + + #[tokio::test] + async fn test_build_multipart_form_file_part_honors_encoding_content_type() { + // A file part with an encoding contentType overrides the default. + let tmp = std::env::temp_dir().join("fern_multipart_override.png"); + std::fs::write(&tmp, b"\x89PNG").unwrap(); + let body = multipart_body_string(vec![MultipartPart::File { + name: "file".into(), + path: tmp.to_string_lossy().into_owned(), + content_type: Some("image/png".into()), + }]) + .await; + let _ = std::fs::remove_file(&tmp); + assert!( + body.contains("Content-Type: image/png"), + "file part Content-Type should come from encoding; got: {body}" + ); + } + #[test] fn test_parse_retry_after_numeric_seconds() { let now = std::time::SystemTime::now(); @@ -3259,6 +3814,7 @@ mod tests { 0, &None, None, + &None, &PaginationConfig::default(), ) .await @@ -3306,6 +3862,7 @@ mod tests { 0, &None, None, + &None, &PaginationConfig::default(), ) .await @@ -3357,6 +3914,7 @@ mod tests { 0, &None, None, + &None, &PaginationConfig::default(), ) .await @@ -3715,6 +4273,96 @@ mod tests { } } + #[test] + fn test_missing_body_param_hint_preserves_dot_notation() { + // Gap 1a: For a body param with dot-notation (e.g. `address.street`), + // the missing-required-param error must suggest `--address.street` + // (dots preserved via `to_kebab_flag`), NOT `--address-street`. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "address.street".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "contacts".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("--address.street"), + "hint must preserve dots for body params: {msg}", + ); + assert!( + !msg.contains("--address-street"), + "hint must NOT kebab-ify dots to hyphens: {msg}", + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_missing_param_hint_uses_builtin_collision_suffix() { + // Gap 1b: When a required param's flag name collides with a builtin + // (e.g. a param named `format`), the hint must suggest + // `--format-param` (the actually registered flag), NOT `--format`. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "format".to_string(), + MethodParameter { + location: Some("query".to_string()), + param_type: Some("string".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "GET".to_string(), + path: "reports".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("--format-param"), + "hint must use the -param suffixed flag for builtin collisions: {msg}", + ); + // Verify it does NOT suggest bare `--format ` (with trailing + // space to avoid matching `--format-param`). + assert!( + !msg.contains("--format ") && !msg.contains("--format,"), + "hint must NOT suggest the bare builtin flag: {msg}", + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + #[test] fn test_per_field_body_flags_path_runs_schema_validation() { // Schema validation must run regardless of whether the body was @@ -3837,6 +4485,62 @@ mod tests { assert!(validate_body_against_schema(&body, "File", &doc).is_ok()); } + #[test] + fn test_validate_body_accepts_null_on_nullable_property() { + // A property whose schema declares `nullable: true` must accept JSON + // null without raising "Expected type 'string', found null". + let mut properties = HashMap::new(); + properties.insert( + "userId".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + nullable: true, + ..Default::default() + }, + ); + let schemas = HashMap::from([( + "Event".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ "userId": null }); + assert!( + validate_body_against_schema(&body, "Event", &doc).is_ok(), + "JSON null on a nullable: true property must validate", + ); + } + + #[test] + fn test_validate_body_rejects_null_on_non_nullable_property() { + // Regression guard: a property with no `nullable` flag must still + // reject JSON null. Keeps the validator strict outside the explicit + // nullable opt-in. + let mut properties = HashMap::new(); + properties.insert( + "code".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let schemas = HashMap::from([( + "Item".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ "code": null }); + let result = validate_body_against_schema(&body, "Item", &doc); + assert!(result.is_err(), "null on non-nullable property must still be rejected"); + } + #[test] fn test_validate_body_open_schema_allows_any_properties() { // A schema with type=object but no properties defined is an open schema: @@ -5139,6 +5843,374 @@ mod tests { assert_eq!(result, vec![("q".to_string(), "hello".to_string())]); } + fn param_with(style: &str, explode: Option) -> MethodParameter { + MethodParameter { + style: Some(style.to_string()), + explode, + ..Default::default() + } + } + + fn path_param(style: &str, explode: Option) -> MethodParameter { + MethodParameter { + location: Some("path".to_string()), + style: Some(style.to_string()), + explode, + ..Default::default() + } + } + + // ── query-param style tests (from main) ────────────────────────────── + + #[test] + fn test_serialize_space_delimited_array() { + // spaceDelimited joins elements with a literal space; the encoder + // turns that into `%20` on the wire. + let value = json!(["1", "2"]); + let result = serialize_query_param("ids", &value, Some(¶m_with("spaceDelimited", Some(false)))); + assert_eq!(result, vec![("ids".to_string(), "1 2".to_string())]); + } + + #[test] + fn test_serialize_pipe_delimited_array() { + let value = json!(["1", "2"]); + let result = serialize_query_param("ids", &value, Some(¶m_with("pipeDelimited", Some(false)))); + assert_eq!(result, vec![("ids".to_string(), "1|2".to_string())]); + } + + #[test] + fn test_serialize_delimited_scalar_degrades_to_value() { + // A non-array under a delimited style is just the scalar value. + let value = json!("solo"); + let result = serialize_query_param("ids", &value, Some(¶m_with("spaceDelimited", Some(false)))); + assert_eq!(result, vec![("ids".to_string(), "solo".to_string())]); + } + + #[test] + fn test_serialize_form_explode_object() { + // form/object/explode=true: each property becomes its own top-level + // key; the parameter name is dropped. + let value = json!({"role": "admin", "active": "true"}); + let result = serialize_query_param("profile", &value, Some(¶m_with("form", Some(true)))); + assert!(result.contains(&("role".to_string(), "admin".to_string())), "got: {result:?}"); + assert!(result.contains(&("active".to_string(), "true".to_string())), "got: {result:?}"); + assert!( + !result.iter().any(|(k, _)| k == "profile"), + "parameter name must be dropped for exploded object; got: {result:?}" + ); + } + + #[test] + fn test_serialize_form_no_explode_object() { + // form/object/explode=false: comma-joined key,value pairs under the + // single parameter key. + let value = json!({"role": "admin"}); + let result = serialize_query_param("profile", &value, Some(¶m_with("form", Some(false)))); + assert_eq!(result, vec![("profile".to_string(), "role,admin".to_string())]); + } + + #[test] + fn test_encode_query_component_space_is_percent20_not_plus() { + // RFC 3986: a literal space encodes as %20, never the form `+`. + assert_eq!(encode_query_component("a b"), "a%20b"); + } + + #[test] + fn test_encode_query_component_reserved_chars() { + assert_eq!(encode_query_component("a&b=c#d"), "a%26b%3Dc%23d"); + // Brackets (deepObject keys) are percent-encoded. + assert_eq!(encode_query_component("filter[status]"), "filter%5Bstatus%5D"); + // The pipe delimiter encodes to %7C. + assert_eq!(encode_query_component("1|2"), "1%7C2"); + } + + #[test] + fn test_encode_query_component_comma_stays_literal() { + // The comma is the form/no-explode delimiter and must stay literal. + assert_eq!(encode_query_component("1,2"), "1,2"); + } + + #[test] + fn test_encode_query_component_unreserved_untouched() { + assert_eq!(encode_query_component("Aa0-_.~"), "Aa0-_.~"); + } + + #[test] + fn test_append_query_string_first_param_uses_question_mark() { + let pairs = vec![("ids".to_string(), "1 2".to_string())]; + assert_eq!( + append_query_string("https://api.example.com/x", &pairs), + "https://api.example.com/x?ids=1%202" + ); + } + + #[test] + fn test_append_query_string_continues_existing_query() { + let pairs = vec![("b".to_string(), "2".to_string())]; + assert_eq!( + append_query_string("https://api.example.com/x?a=1", &pairs), + "https://api.example.com/x?a=1&b=2" + ); + } + + #[test] + fn test_append_query_string_empty_pairs_is_unchanged() { + assert_eq!( + append_query_string("https://api.example.com/x", &[]), + "https://api.example.com/x" + ); + } + + #[test] + fn test_append_query_string_joins_multiple_with_ampersand() { + let pairs = vec![ + ("role".to_string(), "admin".to_string()), + ("active".to_string(), "true".to_string()), + ]; + assert_eq!( + append_query_string("https://api.example.com/x", &pairs), + "https://api.example.com/x?role=admin&active=true" + ); + } + + // ── header style tests (from main) ─────────────────────────────────── + + #[test] + fn test_serialize_header_simple_primitive() { + let v = serialize_header_simple(&json!("hello"), None).unwrap(); + assert_eq!(v, "hello"); + } + + #[test] + fn test_serialize_header_simple_array_comma_joined() { + // simple/array: elements comma-joined, regardless of explode. + let value = json!(["a", "b"]); + let v = serialize_header_simple(&value, None).unwrap(); + assert_eq!(v, "a,b"); + } + + #[test] + fn test_serialize_header_simple_object_no_explode() { + // simple/object explode=false -> k,v,k2,v2 (keys sorted by Map order). + let value = json!({"k": "v", "k2": "v2"}); + let v = serialize_header_simple( + &value, + Some(&MethodParameter { + style: Some("simple".to_string()), + explode: Some(false), + ..Default::default() + }), + ) + .unwrap(); + assert_eq!(v, "k,v,k2,v2"); + } + + #[test] + fn test_serialize_header_simple_object_explode() { + // simple/object explode=true -> k=v,k2=v2. + let value = json!({"k": "v", "k2": "v2"}); + let v = serialize_header_simple( + &value, + Some(&MethodParameter { + explode: Some(true), + ..Default::default() + }), + ) + .unwrap(); + assert_eq!(v, "k=v,k2=v2"); + } + + #[test] + fn test_serialize_header_simple_array_numbers() { + // non-string scalars render via value_to_query_string. + let value = json!([1, 2, 3]); + let v = serialize_header_simple(&value, None).unwrap(); + assert_eq!(v, "1,2,3"); + } + + #[test] + fn test_serialize_header_simple_rejects_control_chars() { + // CR/LF in a value would enable header injection — must be rejected. + let value = json!("a\r\nInjected: yes"); + assert!(serialize_header_simple(&value, None).is_err()); + } + + #[test] + fn test_serialize_header_simple_rejects_control_chars_in_array() { + let value = json!(["ok", "bad\nvalue"]); + assert!(serialize_header_simple(&value, None).is_err()); + } + + // ── path-param style tests ─────────────────────────────────────────── + + #[test] + fn test_serialize_path_param_default_simple_primitive() { + // No definition -> simple/primitive: just the encoded value. + assert_eq!(serialize_path_param("id", &json!("42"), None), "42"); + } + + #[test] + fn test_serialize_path_param_simple_array() { + let def = path_param("simple", Some(false)); + assert_eq!( + serialize_path_param("ids", &json!(["a", "b"]), Some(&def)), + "a,b" + ); + } + + #[test] + fn test_serialize_path_param_simple_object() { + // serde_json sorts object keys -> k1,v1,k2,v2 for this input. + let def = path_param("simple", Some(false)); + assert_eq!( + serialize_path_param("filter", &json!({"k1": "v1", "k2": "v2"}), Some(&def)), + "k1,v1,k2,v2" + ); + } + + #[test] + fn test_serialize_path_param_simple_object_explode() { + let def = path_param("simple", Some(true)); + assert_eq!( + serialize_path_param("filter", &json!({"k1": "v1", "k2": "v2"}), Some(&def)), + "k1=v1,k2=v2" + ); + } + + #[test] + fn test_serialize_path_param_label_primitive() { + let def = path_param("label", None); + assert_eq!(serialize_path_param("id", &json!("42"), Some(&def)), ".42"); + } + + #[test] + fn test_serialize_path_param_label_array() { + // label/array/explode=false: members comma-joined after leading dot. + let def = path_param("label", Some(false)); + assert_eq!( + serialize_path_param("ids", &json!(["a", "b"]), Some(&def)), + ".a,b" + ); + } + + #[test] + fn test_serialize_path_param_label_array_explode() { + // label/array/explode=true: members dot-joined after leading dot. + let def = path_param("label", Some(true)); + assert_eq!( + serialize_path_param("ids", &json!(["a", "b"]), Some(&def)), + ".a.b" + ); + } + + #[test] + fn test_serialize_path_param_label_object_no_explode() { + // label/object/explode=false: flat k,v,k,v comma-joined after leading dot. + let def = path_param("label", Some(false)); + assert_eq!( + serialize_path_param("color", &json!({"R": "100", "G": "200"}), Some(&def)), + ".G,200,R,100" + ); + } + + #[test] + fn test_serialize_path_param_label_object_explode() { + // label/object/explode=true: k=v pairs dot-joined after leading dot. + let def = path_param("label", Some(true)); + assert_eq!( + serialize_path_param("color", &json!({"R": "100", "G": "200"}), Some(&def)), + ".G=200.R=100" + ); + } + + #[test] + fn test_serialize_path_param_matrix_primitive() { + let def = path_param("matrix", None); + assert_eq!( + serialize_path_param("id", &json!("42"), Some(&def)), + ";id=42" + ); + } + + #[test] + fn test_serialize_path_param_matrix_array_no_explode() { + let def = path_param("matrix", Some(false)); + assert_eq!( + serialize_path_param("ids", &json!(["a", "b"]), Some(&def)), + ";ids=a,b" + ); + } + + #[test] + fn test_serialize_path_param_matrix_array_explode() { + let def = path_param("matrix", Some(true)); + assert_eq!( + serialize_path_param("ids", &json!(["a", "b"]), Some(&def)), + ";ids=a;ids=b" + ); + } + + #[test] + fn test_serialize_path_param_matrix_object_explode() { + // matrix/object/explode=true: each k=v gets its own ;k=v prefix. + let def = path_param("matrix", Some(true)); + assert_eq!( + serialize_path_param("color", &json!({"R": "100", "G": "200"}), Some(&def)), + ";G=200;R=100" + ); + } + + #[test] + fn test_serialize_path_param_encodes_values_not_separators() { + // The structural commas stay literal; only the user values are + // percent-encoded (a space -> %20, a comma inside a value -> %2C). + let def = path_param("simple", Some(false)); + assert_eq!( + serialize_path_param("ids", &json!(["a b", "c,d"]), Some(&def)), + "a%20b,c%2Cd" + ); + } + + #[test] + fn test_serialize_path_param_matrix_encodes_value_not_prefix() { + let def = path_param("matrix", None); + assert_eq!( + serialize_path_param("id", &json!("a b"), Some(&def)), + ";id=a%20b" + ); + } + + #[test] + fn test_serialize_path_param_simple_array_with_null() { + // A null element serializes as an empty string. + let def = path_param("simple", Some(false)); + assert_eq!( + serialize_path_param("ids", &json!(["a", null, "b"]), Some(&def)), + "a,,b" + ); + } + + #[test] + fn test_render_path_template_label_style() { + let mut defs: HashMap = HashMap::new(); + defs.insert("id".to_string(), path_param("label", None)); + let mut params = Map::new(); + params.insert("id".to_string(), json!("42")); + let rendered = + render_path_template("/path/label/{id}", ¶ms, Some(&defs)).unwrap(); + assert_eq!(rendered, "/path/label/.42"); + } + + #[test] + fn test_render_path_template_no_defs_falls_back_to_simple() { + // Base-path placeholders pass `None` for defs -> plain encoded value. + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let rendered = + render_path_template("/{tenant}/v1", ¶ms, None).unwrap(); + assert_eq!(rendered, "/acme/v1"); + } + #[test] fn test_get_nested_str_simple() { let val = json!({"nextPageToken": "tok123"}); @@ -6081,8 +7153,8 @@ mod tests { #[test] fn test_build_url_method_root_url_overrides_doc_root_url() { // Per-operation server override: method.root_url must win over doc.root_url. - // If this is broken, requests route to the wrong host (e.g. uploads - // go to api.example.com instead of upload.example.com). + // If this is broken, requests route to the wrong host (e.g. Box uploads + // go to api.box.com instead of upload.box.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), @@ -6180,6 +7252,7 @@ mod tests { 0, &None, None, + &None, &PaginationConfig::default(), ) .await @@ -6214,6 +7287,7 @@ mod tests { 0, &None, None, + &None, &PaginationConfig::default(), ) .await; @@ -6600,6 +7674,7 @@ async fn test_execute_method_dry_run() { None, None, None, + None, // multipart_parts true, // dry_run &pagination, &crate::formatter::OutputPipeline::default(), @@ -6647,6 +7722,7 @@ async fn test_execute_method_missing_path_param() { None, None, None, + None, // multipart_parts true, &PaginationConfig::default(), &crate::formatter::OutputPipeline::default(), @@ -6704,6 +7780,7 @@ async fn test_post_without_body_sets_content_length_zero() { 0, &None, None, + &None, &PaginationConfig::default(), ) .await @@ -6746,6 +7823,7 @@ async fn test_post_with_body_does_not_add_content_length_zero() { 0, &None, None, + &None, &PaginationConfig::default(), ) .await @@ -6786,6 +7864,7 @@ async fn test_get_does_not_set_content_length_zero() { 0, &None, None, + &None, &PaginationConfig::default(), ) .await @@ -6836,6 +7915,7 @@ async fn test_bearer_header_sends_bearer_prefix() { 0, &None, None, + &None, &PaginationConfig::default(), ) .await diff --git a/generators/cli/sdk/src/openapi/overlay.rs b/generators/cli/sdk/src/openapi/overlay.rs index cfea79a0e77f..85659b5da950 100644 --- a/generators/cli/sdk/src/openapi/overlay.rs +++ b/generators/cli/sdk/src/openapi/overlay.rs @@ -1826,4 +1826,109 @@ actions: } } + // ----------------------------------------------------------------------- + // Item 5: Integration test — apply overlay to fixture spec, verify result + // ----------------------------------------------------------------------- + + #[test] + fn test_overlay_on_fixture_spec() { + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); + let overlay = r#" +overlay: "1.0.0" +info: + title: fixture-overlay + version: "1.0.0" +actions: + - target: "$.info" + update: + description: "Modified by overlay" + - target: "$.paths['/users'].get" + update: + x-fern-sdk-method-name: listAllUsers + - target: "$.paths['/users'].get.parameters" + update: + name: offset + in: query + schema: + type: integer + - target: "$.paths['/files/{file_id}/thumbnail']" + remove: true +"#; + let result = + apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); + let doc: serde_json::Value = serde_yaml::from_str(&result).unwrap(); + + // Verify info.description was set + assert_eq!(doc["info"]["description"], "Modified by overlay"); + + // Verify method rename + assert_eq!( + doc["paths"]["/users"]["get"]["x-fern-sdk-method-name"], + "listAllUsers" + ); + + // Verify array append (new parameter added) + let params = doc["paths"]["/users"]["get"]["parameters"] + .as_array() + .unwrap(); + let has_offset = params.iter().any(|p| p["name"] == "offset"); + assert!(has_offset, "offset param should be appended: {params:?}"); + + // Verify remove + assert!( + doc["paths"]["/files/{file_id}/thumbnail"].is_null(), + "thumbnail path should be removed" + ); + + // Verify untouched paths still exist + assert!( + !doc["paths"]["/files/{file_id}"].is_null(), + "other file paths should remain" + ); + } + + #[test] + fn test_overlay_on_fixture_spec_builds_cli_app() { + use crate::openapi::CliApp; + + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); + let overlay = r#" +overlay: "1.0.0" +info: + title: fixture-overlay + version: "1.0.0" +actions: + - target: "$.paths['/files/{file_id}/thumbnail']" + remove: true +"#; + + let app = CliApp::new("overlay-fixture") + .spec(spec) + .overlay(overlay); + let doc = app.build_doc().unwrap(); + + // files and folders groups should still exist + assert!(doc.resources.contains_key("files"), "files group missing"); + assert!(doc.resources.contains_key("folders"), "folders group missing"); + assert!(doc.resources.contains_key("users"), "users group missing"); + + // getThumbnail should be gone from the files resource + let files = &doc.resources["files"]; + assert!( + !files.methods.contains_key("getThumbnail"), + "getThumbnail should be removed: {:?}", + files.methods.keys().collect::>() + ); + // Other file operations should still exist + assert!( + files.methods.contains_key("get"), + "get should remain: {:?}", + files.methods.keys().collect::>() + ); + assert!( + files.methods.contains_key("update"), + "update should remain: {:?}", + files.methods.keys().collect::>() + ); + } } diff --git a/generators/cli/sdk/src/openapi/parser.rs b/generators/cli/sdk/src/openapi/parser.rs index 3cacb875f088..97bd10971431 100644 --- a/generators/cli/sdk/src/openapi/parser.rs +++ b/generators/cli/sdk/src/openapi/parser.rs @@ -10,14 +10,14 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, - JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, - RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, - StreamingConfig, + JsonSchemaProperty, MethodParameter, MultipartField, PaginationConfig, RestDescription, + RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, + SecurityScheme, StreamingConfig, }; use crate::error::CliError; /// Deserialize `x-fern-sdk-group-name` as either a string scalar or a list of -/// strings. The Fern extension allows both forms; some specs use +/// strings. The Fern extension allows both forms; specs like AssemblyAI's use /// the scalar form while internal fixtures use the list form for nesting. fn deserialize_group_name<'de, D>(deserializer: D) -> Result>, D::Error> where @@ -414,6 +414,11 @@ struct OpenApiOperation { /// headers. #[serde(rename = "x-fern-idempotent", default)] x_fern_idempotent: Option, + /// `x-fern-cli-idempotency: false` opt-out for auto Idempotency-Key. + /// When explicitly `false`, the executor does NOT inject the + /// auto-generated `Idempotency-Key` header on POST/PUT/PATCH. + #[serde(rename = "x-fern-cli-idempotency", default)] + x_fern_cli_idempotency: Option, /// Raw `x-fern-sdk-return-value` extension on the operation. Mirrors /// fern-api/fern's `FernOpenAPIExtension.RESPONSE_PROPERTY` — a /// dot-separated key path into the JSON response body identifying @@ -579,6 +584,19 @@ struct OpenApiRequestBody { #[derive(Debug, Deserialize)] struct OpenApiMediaType { schema: Option, + /// OpenAPI `encoding` object — per-property serialization overrides. + /// Only `contentType` is consumed (for multipart/form-data part + /// headers); `style` / `explode` / `headers` / `allowReserved` are not + /// yet acted upon. + #[serde(default)] + encoding: HashMap, +} + +/// A single entry in the OpenAPI `encoding` object. +#[derive(Debug, Deserialize, Default)] +struct OpenApiEncoding { + #[serde(rename = "contentType")] + content_type: Option, } /// Captures the OpenAPI `type` field across the 3.0 string form @@ -741,7 +759,7 @@ struct OpenApiSchemaObject { /// - OpenAPI 3.1 array of values: `examples: [a, b]` /// - OpenAPI 3.0 MediaType-style map: `examples: { name: { value: ... } }` /// (technically out-of-spec at the schema level, but several - /// real-world specs embed this form) + /// real-world specs — e.g. BigCommerce — embed this form) /// - Single value /// /// Downstream code is free to interpret the value based on its shape. @@ -2527,7 +2545,8 @@ pub fn load_openapi_spec_from_value( // Handle request body — also harvests body-located parameters so // the command builder can render per-field flags alongside `--json`. - let (request, binary_request_body, body_encoding, body_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params, multipart_fields) = + extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2593,6 +2612,13 @@ pub fn load_openapi_spec_from_value( let idempotent = operation.x_fern_idempotent.unwrap_or(false); + // `x-fern-cli-idempotency: false` explicitly disables the + // auto-generated Idempotency-Key header on this operation. + let no_auto_idempotency_key = operation + .x_fern_cli_idempotency + .map(|v| !v) + .unwrap_or(false); + // `x-fern-audiences` is an array of strings; missing means // `[]`. Stored verbatim so the command-tree filter can // mirror fern's `some(...)` membership check exactly. See @@ -2653,11 +2679,13 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + multipart_fields, body_encoding, security_requirements, pagination, availability, idempotent, + no_auto_idempotency_key, return_value, streaming, retries, @@ -2721,7 +2749,18 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_encoding, body_params)`: +/// Result of [`extract_request_body`], as +/// `(json_schema, binary_body, body_encoding, body_params, multipart_fields)`. +/// See the function docs for per-field semantics. +type ExtractedRequestBody = ( + Option, + Option, + BodyEncoding, + HashMap, + Vec, +); + +/// Returns `(json_schema, binary_body, body_encoding, body_params, multipart_fields)`: /// - `json_schema`: a SchemaRef for the JSON request body (if `application/json` is declared). /// - `binary_body`: metadata when the operation expects a raw binary body /// (any non-JSON / non-form media type). @@ -2730,24 +2769,24 @@ const MAX_BODY_DEPTH: u8 = 3; /// each property up to MAX_BODY_DEPTH is exposed as a body-located [`MethodParameter`] /// with dotted keys for nested fields. `$ref` bodies are resolved from /// `component_schemas` and their properties flattened with the same depth rules. +/// - `multipart_fields`: per-field metadata for `multipart/form-data` bodies. fn extract_request_body( request_body: &Option, operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, BodyEncoding, HashMap) { +) -> ExtractedRequestBody { let Some(body) = request_body.as_ref() else { - return (None, None, BodyEncoding::Json, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new(), Vec::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, BodyEncoding::Json, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new(), Vec::new()); }; if let Some(media) = content.get("application/json") { if let Some(schema_obj) = media.schema.as_ref() { if let Some(ref_path) = &schema_obj.schema_ref { let name = strip_ref_prefix(ref_path); - // Resolve the $ref from components/schemas and flatten its properties. let body_params = component_schemas .get(&name) .map(|resolved| flatten_body_params(resolved, component_schemas, 0)) @@ -2760,6 +2799,7 @@ fn extract_request_body( None, BodyEncoding::Json, body_params, + Vec::new(), ); } @@ -2777,11 +2817,27 @@ fn extract_request_body( None, BodyEncoding::Json, body_params, + Vec::new(), ); } } - // No JSON body declared — check for form-urlencoded body next. + // Handle multipart/form-data bodies. Each property in the schema + // becomes a CLI flag; file-typed fields accept a path and are streamed + // as binary parts. + if let Some(media) = content.get("multipart/form-data") { + let multipart_fields = extract_multipart_fields( + media.schema.as_ref(), + &media.encoding, + component_schemas, + operation_id, + ); + if !multipart_fields.is_empty() { + return (None, None, BodyEncoding::Json, HashMap::new(), multipart_fields); + } + } + + // No JSON or multipart body declared — check for form-urlencoded body next. if let Some(media) = content.get("application/x-www-form-urlencoded") { if let Some(schema_obj) = media.schema.as_ref() { if let Some(ref_path) = &schema_obj.schema_ref { @@ -2798,6 +2854,7 @@ fn extract_request_body( None, BodyEncoding::FormUrlEncoded, body_params, + Vec::new(), ); } @@ -2815,17 +2872,19 @@ fn extract_request_body( None, BodyEncoding::FormUrlEncoded, body_params, + Vec::new(), ); } } - // No JSON or form body — look for a binary content type. `multipart/form-data` - // is explicitly excluded (separate future work). + // No JSON, multipart, or form body — look for a binary content type. + // `multipart/form-data` and `application/x-www-form-urlencoded` are + // explicitly excluded (handled above). let Some((content_type, media)) = content.iter().find(|(ct, _)| { let ct = ct.as_str(); ct != "application/x-www-form-urlencoded" && ct != "multipart/form-data" }) else { - return (None, None, BodyEncoding::Json, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new(), Vec::new()); }; let is_binary_format = media @@ -2855,9 +2914,124 @@ fn extract_request_body( }), BodyEncoding::Json, HashMap::new(), + Vec::new(), ) } +/// Walk a `multipart/form-data` schema and emit one [`MultipartField`] per +/// property. Resolves `$ref` to `components/schemas` for the root schema +/// and for individual properties. File fields are identified by +/// `type: string, format: binary` (or legacy `type: file`). +/// +/// `encoding` is the media type's OpenAPI `encoding` object; a per-property +/// `contentType` there overrides the content type inferred from the schema +/// (the default being `application/octet-stream` for file parts and +/// `text/plain` for text parts). This is the OAS 3.x mechanism for, e.g., +/// declaring that a string field carries `application/json`. +fn extract_multipart_fields( + schema: Option<&OpenApiSchemaObject>, + encoding: &HashMap, + component_schemas: &HashMap, + operation_id: &str, +) -> Vec { + let Some(schema) = schema else { + tracing::warn!( + operation = operation_id, + "multipart/form-data body has no schema; skipping", + ); + return Vec::new(); + }; + + // Resolve top-level $ref. + let resolved = if let Some(ref_path) = &schema.schema_ref { + let name = strip_ref_prefix(ref_path); + match component_schemas.get(&name) { + Some(r) => r, + None => { + tracing::warn!( + operation = operation_id, + schema_ref = name, + "unresolvable $ref for multipart body; skipping", + ); + return Vec::new(); + } + } + } else { + schema + }; + + if resolved.schema_type() != Some("object") { + tracing::warn!( + operation = operation_id, + "multipart/form-data schema is not an object; skipping", + ); + return Vec::new(); + } + + let required_set: std::collections::HashSet<&str> = + resolved.required.iter().map(String::as_str).collect(); + + let mut fields: Vec = resolved + .properties + .iter() + .map(|(name, prop)| { + let (is_file, inferred_ct) = classify_multipart_property(prop, component_schemas); + // A `contentType` in the `encoding` object wins over the + // schema-inferred default for this part. + let content_type = encoding + .get(name) + .and_then(|e| e.content_type.clone()) + .or(inferred_ct); + MultipartField { + wire_name: name.clone(), + is_file, + description: prop.description.clone(), + required: required_set.contains(name.as_str()), + content_type, + } + }) + .collect(); + fields.sort_by(|a, b| a.wire_name.cmp(&b.wire_name)); + fields +} + +/// Determine whether a multipart property is a file upload and its content type. +fn classify_multipart_property( + prop: &OpenApiSchemaObject, + component_schemas: &HashMap, +) -> (bool, Option) { + // Resolve $ref if present. + let resolved = if let Some(ref_path) = &prop.schema_ref { + let name = strip_ref_prefix(ref_path); + component_schemas.get(&name).unwrap_or(prop) + } else { + prop + }; + + let ty = resolved.schema_type(); + let fmt = resolved.format.as_deref(); + + // `type: string, format: binary` or legacy `type: file` + if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { + let ct = Some("application/octet-stream".to_string()); + return (true, ct); + } + + // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) + if ty == Some("array") { + if let Some(items) = &resolved.items { + if (items.schema_type() == Some("string") + && items.format.as_deref() == Some("binary")) + || items.schema_type() == Some("file") + { + return (true, Some("application/octet-stream".to_string())); + } + } + } + + (false, None) +} + /// Recursively walk an object schema and emit one body-located [`MethodParameter`] /// per property, up to `MAX_BODY_DEPTH` levels deep. Nested object properties /// use dotted keys (e.g. `"name.first"`). Array properties set `repeated: true` @@ -2871,6 +3045,21 @@ fn flatten_body_params( flatten_body_params_prefix(schema, component_schemas, depth, "") } +/// True when the schema admits JSON `null` *and* its base type is a scalar +/// the CLI lowers to a single flag (`string` / `integer` / `number` / +/// `boolean`). Composite types (`array`, `object`) stay false even when the +/// schema marks them nullable — see ADR-0003 for why the null-sentinel +/// surface is scalar-only. +fn is_scalar_nullable(obj: &OpenApiSchemaObject) -> bool { + if !obj.is_nullable() { + return false; + } + matches!( + obj.schema_type(), + Some("string") | Some("integer") | Some("number") | Some("boolean"), + ) +} + fn flatten_body_params_prefix( schema: &OpenApiSchemaObject, component_schemas: &HashMap, @@ -2926,6 +3115,7 @@ fn flatten_body_params_prefix( enum_values: effective_enum_values(resolved), default_value: const_default, repeated: is_array, + nullable: is_scalar_nullable(resolved), ..Default::default() }, ); @@ -2963,6 +3153,7 @@ fn flatten_body_params_prefix( enum_values: effective_enum_values(prop), default_value: const_default, repeated: is_array, + nullable: is_scalar_nullable(prop), ..Default::default() }, ); @@ -3070,7 +3261,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens. + // BigCommerce-style: op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -3095,7 +3286,7 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // When operationId doesn't start with tag → method + // BigCommerce-shape: operationId doesn't start with tag → method // stays as full kebab'd operationId. Matches Fern's behavior. let yaml = r#" openapi: "3.0.0" @@ -3194,9 +3385,264 @@ paths: assert_eq!(binary.flag_name, "body"); } + #[test] + fn test_multipart_form_data_fields_parsed() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /uploads: + post: + x-fern-sdk-group-name: uploads + x-fern-sdk-method-name: create + operationId: uploadsCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: The file to upload + purpose: + type: string + description: Purpose of the upload + responses: { "201": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["uploads"].methods["create"]; + assert_eq!(create.multipart_fields.len(), 2); + + let file_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!(file_field.is_file); + assert!(file_field.required); + assert_eq!( + file_field.description.as_deref(), + Some("The file to upload") + ); + + let purpose_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "purpose") + .expect("purpose field missing"); + assert!(!purpose_field.is_file); + assert!(!purpose_field.required); + } + + #[test] + fn test_multipart_form_data_with_ref_schema() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /files: + post: + x-fern-sdk-group-name: files + x-fern-sdk-method-name: upload + operationId: filesUpload + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/FileUpload' + responses: { "200": { description: ok } } +components: + schemas: + FileUpload: + type: object + required: [content] + properties: + content: + type: string + format: binary + label: + type: string +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let upload = &doc.resources["files"].methods["upload"]; + assert_eq!(upload.multipart_fields.len(), 2); + + let content = upload + .multipart_fields + .iter() + .find(|f| f.wire_name == "content") + .expect("content field missing"); + assert!(content.is_file); + assert!(content.required); + + let label = upload + .multipart_fields + .iter() + .find(|f| f.wire_name == "label") + .expect("label field missing"); + assert!(!label.is_file); + assert!(!label.required); + } + + #[test] + fn test_multipart_encoding_content_type_overrides_inferred() { + // The OpenAPI `encoding` object pins a per-part Content-Type that + // wins over the schema-inferred default: here `metadata` is a plain + // string (would default to a text part) but is declared + // `application/json`, and the file part's octet-stream default is + // overridden to `image/png`. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /uploads: + post: + x-fern-sdk-group-name: uploads + x-fern-sdk-method-name: create + operationId: uploadsCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + metadata: + type: string + encoding: + file: + contentType: image/png + metadata: + contentType: application/json + responses: { "201": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["uploads"].methods["create"]; + + let file_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!(file_field.is_file); + assert_eq!( + file_field.content_type.as_deref(), + Some("image/png"), + "encoding.contentType should override the octet-stream default", + ); + + let metadata_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "metadata") + .expect("metadata field missing"); + assert!(!metadata_field.is_file); + assert_eq!( + metadata_field.content_type.as_deref(), + Some("application/json"), + "a text part can carry a per-part Content-Type via encoding", + ); + } + + #[test] + fn test_multipart_file_part_defaults_to_octet_stream_without_encoding() { + // Absent an `encoding` entry, a binary part keeps the OpenAPI + // default content type and a text part stays untyped (None → + // reqwest's text/plain at send time). + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /uploads: + post: + x-fern-sdk-group-name: uploads + x-fern-sdk-method-name: create + operationId: uploadsCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + note: + type: string + responses: { "201": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["uploads"].methods["create"]; + + let file_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .unwrap(); + assert_eq!( + file_field.content_type.as_deref(), + Some("application/octet-stream"), + ); + + let note_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "note") + .unwrap(); + assert_eq!(note_field.content_type, None); + } + + #[test] + fn test_multipart_does_not_produce_json_body() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /uploads: + post: + x-fern-sdk-group-name: uploads + x-fern-sdk-method-name: create + operationId: uploadsCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["uploads"].methods["create"]; + assert!( + create.request.is_none(), + "multipart ops should not have a JSON request schema" + ); + assert!( + create.binary_request_body.is_none(), + "multipart ops should not have a binary_request_body" + ); + assert!( + !create.multipart_fields.is_empty(), + "multipart ops should have multipart_fields" + ); + } + #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` + // AssemblyAI and other Fern specs write `x-fern-sdk-group-name: transcripts` // as a bare string; the parser should accept it as a single-element list. let yaml = r#" openapi: "3.0.0" @@ -4370,6 +4816,69 @@ paths: assert_eq!(users.root_url, "https://api.example.com"); } + #[test] + fn test_box_upload_operations_use_upload_server() { + let yaml = include_str!("../../cli/box/openapi.yaml"); + let doc = load_openapi_spec(yaml, "box").unwrap(); + // At least one resource must have methods that route to upload.box.com + let upload_methods: Vec<_> = doc.resources.values() + .flat_map(|r| r.methods.values()) + .filter(|m| m.root_url.contains("upload.box.com")) + .collect(); + assert!( + !upload_methods.is_empty(), + "expected at least one method routing to upload.box.com, found none" + ); + } + + #[test] + fn test_load_box_spec() { + let yaml = include_str!("../../cli/box/openapi.yaml"); + let doc = load_openapi_spec(yaml, "box").unwrap(); + assert_eq!(doc.name, "box"); + assert_eq!(doc.root_url, "https://api.box.com/2.0"); + + // Box spec has 73 top-level resource groups: 72 with x-fern-sdk-group-name, + // plus one ("metadata taxonomies") surfaced by the tag-based fallback. + assert_eq!(doc.resources.len(), 73); + + // Check specific groups exist + assert!(doc.resources.contains_key("files")); + assert!(doc.resources.contains_key("folders")); + assert!(doc.resources.contains_key("users")); + assert!(doc.resources.contains_key("collaborations")); + + // Check users has 6 methods + let users = &doc.resources["users"]; + assert_eq!(users.methods.len(), 6); + assert!(users.methods.contains_key("get")); + assert!(users.methods.contains_key("list")); + assert!(users.methods.contains_key("create")); + assert!(users.methods.contains_key("getCurrent")); + + // Check a method's HTTP method and path + let get_user = &users.methods["get"]; + assert_eq!(get_user.http_method, "GET"); + assert_eq!(get_user.path, "/users/{user_id}"); + + // Check parameters + assert!(get_user.parameters.contains_key("user_id")); + let user_id_param = &get_user.parameters["user_id"]; + assert_eq!(user_id_param.location.as_deref(), Some("path")); + assert!(user_id_param.required); + } + + #[test] + fn test_load_bigcommerce_spec() { + // BigCommerce ships per-domain specs (vendored from docs-v2). Pick a + // representative one to verify the parser handles them — the CLI binary + // wires up all 36 of them via `spec_under` namespaces. + let yaml = include_str!("../../cli/bigcommerce/specs/management/customers.v3.yml"); + let doc = load_openapi_spec(yaml, "bigcommerce").unwrap(); + assert_eq!(doc.name, "bigcommerce"); + assert!(doc.root_url.contains("api.bigcommerce.com")); + } + // ------------------------------------------------------------------ // x-fern-idempotent + x-fern-idempotency-headers (FER-9864 P1). // ------------------------------------------------------------------ @@ -6086,6 +6595,56 @@ paths: assert!(!map.contains_key("remove"), "null inside object array element should be removed"); } + // -- Verification: from_str vs from_value round-trip -------------------- + + /// Compare `load_openapi_spec` (from_str → Value → from_value) against + /// each real spec to ensure the round-trip path doesn't lose or corrupt + /// any operations, resources, or schemas. + #[test] + fn test_roundtrip_bigcommerce_customers_v3() { + let yaml = include_str!("../../cli/bigcommerce/specs/management/customers.v3.yml"); + let doc = load_openapi_spec(yaml, "bigcommerce").unwrap(); + assert!(!doc.resources.is_empty(), "customers.v3 should have resources"); + assert!(!doc.schemas.is_empty(), "customers.v3 should have schemas"); + } + + #[test] + fn test_roundtrip_bigcommerce_orders_v2() { + let yaml = include_str!("../../cli/bigcommerce/specs/management/orders.v2.yml"); + let doc = load_openapi_spec(yaml, "bigcommerce").unwrap(); + assert!(!doc.resources.is_empty(), "orders.v2 should have resources"); + } + + #[test] + fn test_roundtrip_box_spec() { + let yaml = include_str!("../../cli/box/openapi.yaml"); + let doc = load_openapi_spec(yaml, "box").unwrap(); + assert!(!doc.resources.is_empty(), "box should have resources"); + assert!(!doc.schemas.is_empty(), "box should have schemas"); + } + + /// Verify the from_str → from_value path produces identical results to + /// what the old direct from_str path would have produced. We compare + /// resource keys and method counts to ensure no operations are lost. + #[test] + fn test_roundtrip_consistency_all_bigcommerce_specs() { + let specs: &[(&str, &str)] = &[ + ("customers.v3", include_str!("../../cli/bigcommerce/specs/management/customers.v3.yml")), + ("orders.v3", include_str!("../../cli/bigcommerce/specs/management/orders.v3.yml")), + ("orders.v2", include_str!("../../cli/bigcommerce/specs/management/orders.v2.yml")), + ("checkouts.v3", include_str!("../../cli/bigcommerce/specs/management/checkouts.v3.yml")), + ("channels.v3", include_str!("../../cli/bigcommerce/specs/management/channels.v3.yml")), + ("carts.v3", include_str!("../../cli/bigcommerce/specs/management/carts.v3.yml")), + ("shipping.v3", include_str!("../../cli/bigcommerce/specs/management/shipping.v3.yml")), + ]; + for (name, yaml) in specs { + let doc = load_openapi_spec(yaml, "bigcommerce"); + assert!(doc.is_ok(), "spec {name} should parse without error: {:?}", doc.err()); + let doc = doc.unwrap(); + assert!(!doc.resources.is_empty(), "spec {name} should have resources"); + } + } + // -- Verification: allowNullKeys covers all Fern CLI keys --------------- #[test] @@ -6136,6 +6695,39 @@ paths: assert!(item["source"].is_null(), "null under x-code-samples should be preserved"); } + // -- Verification: real overrides e2e ----------------------------------- + + #[test] + fn test_real_overrides_e2e_bigcommerce_customers_v3() { + let base_yaml = include_str!("../../cli/bigcommerce/specs/management/customers.v3.yml"); + let overrides_yaml = r#" +paths: + /customers: + get: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: list + post: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: create + /customers/{customerId}: + put: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: update + delete: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: delete +"#; + let base: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); + let ovr: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); + let merged = deep_merge_yaml(base, ovr); + let doc = load_openapi_spec_from_value(merged, "bigcommerce").unwrap(); + let customers = &doc.resources["customers"]; + assert!(customers.methods.contains_key("list"), "override should create 'list' method"); + assert!(customers.methods.contains_key("create"), "override should create 'create' method"); + assert!(customers.methods.contains_key("update"), "override should create 'update' method"); + assert!(customers.methods.contains_key("delete"), "override should create 'delete' method"); + } + // -- Verification: all_objects heuristic -------------------------------- #[test] @@ -8046,6 +8638,31 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- Real-world OpenAPI 3.1 specs (parse sweep) ---------------------- + // + // These fixtures are real customer/prospect specs that declare + // `openapi: 3.1.0`. They sweep the new 3.1 surface area (type arrays, + // numeric exclusive bounds, const, composition, webhooks, schema-level + // examples) against actual wire shapes rather than synthetic snippets. + + #[test] + fn test_real_31_devin_spec_parses() { + let yaml = include_str!("../../cli/devin/openapi.yaml"); + load_openapi_spec(yaml, "devin").expect("devin 3.1 spec should parse"); + } + + #[test] + fn test_real_31_paid_spec_parses() { + let yaml = include_str!("../../cli/paid/specs/openapi.yml"); + load_openapi_spec(yaml, "paid").expect("paid 3.1 spec should parse"); + } + + #[test] + fn test_real_31_assemblyai_spec_parses() { + let yaml = include_str!("../../cli/assemblyai/specs/openapi.yml"); + load_openapi_spec(yaml, "assemblyai").expect("assemblyai 3.1 spec should parse"); + } + // -- JSON Schema composition (oneOf / anyOf / allOf) ----------------- #[test] @@ -8164,7 +8781,7 @@ paths: #[test] fn test_examples_lax_30_map_form() { - // Schema-level `examples` map (out-of-spec for + // BigCommerce-style schema-level `examples` map (out-of-spec for // OpenAPI 3.0 at the schema level, but real-world specs use it). // The parser must round-trip without erroring. let obj: OpenApiSchemaObject = serde_yaml::from_str( @@ -8553,6 +9170,126 @@ components: assert_eq!(s_31.schema_type.as_deref(), Some("object")); } + #[test] + fn test_flatten_body_params_marks_scalar_nullable_30() { + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + userId: + type: string + nullable: true + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + let user_id = params.get("userId").expect("userId flag should be emitted"); + assert!(user_id.nullable, "scalar nullable (3.0) must propagate to MethodParameter"); + } + + #[test] + fn test_flatten_body_params_marks_scalar_nullable_31() { + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + userId: + type: ["string", "null"] + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + let user_id = params.get("userId").expect("userId flag should be emitted"); + assert!(user_id.nullable, "scalar nullable (3.1) must propagate to MethodParameter"); + } + + #[test] + fn test_flatten_body_params_non_nullable_scalar_stays_false() { + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + userId: + type: string + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + let user_id = params.get("userId").unwrap(); + assert!(!user_id.nullable); + } + + #[test] + fn test_flatten_body_params_nullable_integer_and_boolean_and_number() { + // Three scalar variants beyond string: all must propagate nullable. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + count: + type: ["integer", "null"] + active: + type: ["boolean", "null"] + ratio: + type: ["number", "null"] + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + assert!(params["count"].nullable); + assert!(params["active"].nullable); + assert!(params["ratio"].nullable); + } + + #[test] + fn test_flatten_body_params_non_scalar_nullable_not_propagated() { + // type: [array, null] and type: [object, null] must NOT set + // param.nullable=true. Arrays + nullable would collide with + // ArgAction::Append; object-nullable has no parent flag to attach to. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + tags: + type: ["array", "null"] + items: + type: string + metadata: + type: ["object", "null"] + properties: + code: + type: string + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + // tags is an array → repeated, single flag, nullable must be false. + assert!(!params["tags"].nullable, "nullable array must not set param.nullable"); + // metadata is flattened to metadata.code; the only emitted flag is + // the inner scalar, which is non-nullable. + assert!(!params["metadata.code"].nullable); + } + + #[test] + fn test_flatten_body_params_nested_nullable_via_dot_notation() { + // metadata.code where code is nullable: dot-notation should preserve + // the nullable flag on the leaf. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + metadata: + type: object + properties: + code: + type: ["string", "null"] + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + assert!(params["metadata.code"].nullable); + } + #[test] fn test_nullable_schema_object_lowering() { let obj_30: OpenApiSchemaObject = serde_yaml::from_str( diff --git a/generators/cli/sdk/src/openapi/skill_emitter.rs b/generators/cli/sdk/src/openapi/skill_emitter.rs index aecee7c01b96..327b0e8b5066 100644 --- a/generators/cli/sdk/src/openapi/skill_emitter.rs +++ b/generators/cli/sdk/src/openapi/skill_emitter.rs @@ -248,7 +248,8 @@ fn describe_credential_source(src: &AuthCredentialSource) -> String { AuthCredentialSource::Cli(arg) => format!("`--{arg}` flag"), AuthCredentialSource::File(path) => format!("`{}` file", path.display()), AuthCredentialSource::Literal(_) => "built-in literal".to_string(), - AuthCredentialSource::Closure(_) => "custom resolver".to_string(), + AuthCredentialSource::Closure(_, Some(hint)) => hint.clone(), + AuthCredentialSource::Closure(_, None) => "custom resolver".to_string(), AuthCredentialSource::Chain(sources) => sources .iter() .map(describe_credential_source) diff --git a/generators/cli/sdk/src/text.rs b/generators/cli/sdk/src/text.rs index b66cb4446ae2..9b4df3bb05ec 100644 --- a/generators/cli/sdk/src/text.rs +++ b/generators/cli/sdk/src/text.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 +use unicode_normalization::UnicodeNormalization; + /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; @@ -41,6 +43,145 @@ pub fn to_screaming_snake(s: &str) -> String { to_kebab_flag(s).to_ascii_uppercase().replace('-', "_") } +/// Sanitize an OpenAPI parameter wire name into a valid CLI flag name. +/// +/// Pipeline (applied in order): +/// +/// 1. **Reject** names containing ASCII control characters (`\x00`–`\x1f`, +/// `\x7f`) or whitespace (space, tab, newline, carriage return). +/// 2. **NFKD transliterate**: decompose Unicode, strip combining marks, and +/// drop zero-width / bidi-control codepoints. Reject names that still +/// contain non-ASCII characters after decomposition (CJK, RTL, etc.). +/// 3. **Kebab-case normalize**: apply camelCase / snake_case / PascalCase → +/// kebab-case conversion, and replace any remaining character outside +/// `[A-Za-z0-9]` with `-`. +/// 4. **Tidy**: collapse repeated `-` to one, trim leading/trailing `-`. +/// +/// Returns `Ok(flag_name)` or `Err(message)` describing why the name is +/// invalid. The caller is responsible for reserved-name and collision +/// checks (those require cross-parameter context). +pub fn sanitize_flag_name(wire_name: &str) -> Result { + if wire_name.is_empty() { + return Err("Parameter name is empty".to_string()); + } + + // Step 1: reject control characters and whitespace. + for ch in wire_name.chars() { + if ch.is_ascii_control() { + return Err(format!( + "Parameter '{wire_name}' contains control character U+{:04X}", + ch as u32, + )); + } + if ch.is_whitespace() { + return Err(format!( + "Parameter '{wire_name}' contains whitespace character U+{:04X}", + ch as u32, + )); + } + } + + // Step 2: NFKD decompose → strip combining marks and zero-width/bidi + // codepoints → reject remaining non-ASCII. + let decomposed: String = wire_name + .nfkd() + .filter(|ch| { + // Drop combining marks (Unicode category M). + if is_combining_mark(*ch) { + return false; + } + // Drop zero-width and bidi-control codepoints. + if is_zero_width_or_bidi(*ch) { + return false; + } + true + }) + .collect(); + + for ch in decomposed.chars() { + if !ch.is_ascii() { + return Err(format!( + "Parameter '{wire_name}' contains non-transliterable character '{ch}' (U+{:04X})", + ch as u32, + )); + } + } + + // Steps 3 + 4: kebab-case normalize with extended sanitization, then tidy. + let sanitized = to_kebab_flag_sanitized(&decomposed); + + if sanitized.is_empty() { + return Err(format!( + "Parameter '{wire_name}' sanitizes to an empty string", + )); + } + + Ok(sanitized) +} + +/// Like [`to_kebab_flag`] but treats *any* non-alphanumeric character as a +/// word separator (not just `_` and `-`). This catches `:`, `.`, `[`, `]`, +/// `{`, `}`, `+`, `,`, `=`, etc. — all the shell-special and +/// POSIX-flag-name-violating characters listed in the sanitization spec. +fn to_kebab_flag_sanitized(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for (i, ch) in s.chars().enumerate() { + if ch.is_ascii_alphanumeric() { + if ch.is_ascii_uppercase() { + // camelCase word boundary: insert dash before uppercase + // unless we're at the start or already have a dash. + if i > 0 && !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_ascii_lowercase()); + } else { + result.push(ch); + } + } else { + // Any non-alphanumeric → separator dash (collapsed later). + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } + } + // Trim trailing dashes. + while result.ends_with('-') { + result.pop(); + } + result +} + +/// Returns `true` for Unicode combining marks (category M: Mn, Mc, Me). +fn is_combining_mark(ch: char) -> bool { + // Combining Diacritical Marks: U+0300–U+036F + // Combining Diacritical Marks Extended: U+1AB0–U+1AFF + // Combining Diacritical Marks Supplement: U+1DC0–U+1DFF + // Combining Diacritical Marks for Symbols: U+20D0–U+20FF + // Combining Half Marks: U+FE20–U+FE2F + matches!(ch, + '\u{0300}'..='\u{036F}' + | '\u{1AB0}'..='\u{1AFF}' + | '\u{1DC0}'..='\u{1DFF}' + | '\u{20D0}'..='\u{20FF}' + | '\u{FE20}'..='\u{FE2F}' + ) +} + +/// Returns `true` for zero-width and bidi-control codepoints that should +/// be silently stripped from parameter names. +fn is_zero_width_or_bidi(ch: char) -> bool { + matches!(ch, + '\u{200B}' // ZERO WIDTH SPACE + | '\u{200C}' // ZERO WIDTH NON-JOINER + | '\u{200D}' // ZERO WIDTH JOINER + | '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE (BOM) + | '\u{200E}' // LEFT-TO-RIGHT MARK + | '\u{200F}' // RIGHT-TO-LEFT MARK + | '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO + | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI + ) +} + /// Truncates a description string to `max_chars` using smart boundaries. /// /// When `strip_links` is true, markdown links `[text](url)` are replaced with @@ -324,4 +465,84 @@ mod tests { assert_eq!(to_screaming_snake("uuid"), "UUID"); assert_eq!(to_screaming_snake(""), ""); } + + // ------------------------------------------------------------------ + // sanitize_flag_name — FER-10430 parametrized table + // ------------------------------------------------------------------ + + #[test] + fn test_sanitize_flag_name_table() { + let cases: &[(&str, &str)] = &[ + ("id:in", "id-in"), + ("customer_group_id:in", "customer-group-id-in"), + ("date_created:min", "date-created-min"), + ("email:like", "email-like"), + ("customer_id", "customer-id"), + ("customerId", "customer-id"), + ("address.street", "address-street"), + ("tag[0]", "tag-0"), + ("customer{id}", "customer-id"), + ("q+filter", "q-filter"), + ("category,subcategory", "category-subcategory"), + ("na\u{00ef}ve", "naive"), // naïve → NFKD + ("from-date", "from-date"), // already valid + ("__proto__", "proto"), // leading/trailing trimmed + ]; + for (wire, expected) in cases { + let result = sanitize_flag_name(wire).unwrap_or_else(|e| { + panic!("sanitize_flag_name({wire:?}) returned Err: {e}") + }); + assert_eq!( + result, *expected, + "sanitize_flag_name({wire:?}): got {result:?}, expected {expected:?}", + ); + } + } + + #[test] + fn test_sanitize_flag_name_rejects_control_chars() { + let result = sanitize_flag_name("id\x00in"); + assert!(result.is_err(), "control char should be rejected"); + assert!(result.unwrap_err().contains("control character")); + } + + #[test] + fn test_sanitize_flag_name_rejects_whitespace() { + let result = sanitize_flag_name("id in"); + assert!(result.is_err(), "whitespace should be rejected"); + assert!(result.unwrap_err().contains("whitespace")); + } + + #[test] + fn test_sanitize_flag_name_rejects_cjk() { + let result = sanitize_flag_name("\u{5DF2}\u{8BFB}"); + assert!(result.is_err(), "CJK should be rejected"); + assert!(result.unwrap_err().contains("non-transliterable")); + } + + #[test] + fn test_sanitize_flag_name_idempotence() { + let cases = &["id:in", "customer_group_id:in", "address.street", "na\u{00ef}ve"]; + for wire in cases { + let first = sanitize_flag_name(wire).unwrap(); + let second = sanitize_flag_name(&first).unwrap(); + assert_eq!( + first, second, + "sanitize_flag_name should be idempotent for {wire:?}: first={first:?}, second={second:?}", + ); + } + } + + #[test] + fn test_sanitize_flag_name_empty_rejected() { + assert!(sanitize_flag_name("").is_err()); + } + + #[test] + fn test_sanitize_flag_name_strips_zero_width() { + // Zero-width space inside a name is silently stripped (invisible, + // so the adjacent letters merge). + let result = sanitize_flag_name("foo\u{200B}bar").unwrap(); + assert_eq!(result, "foobar"); + } } diff --git a/generators/cli/sdk/src/websocket/auth.rs b/generators/cli/sdk/src/websocket/auth.rs index 4b74abfd54d2..9d448a637c29 100644 --- a/generators/cli/sdk/src/websocket/auth.rs +++ b/generators/cli/sdk/src/websocket/auth.rs @@ -57,15 +57,15 @@ const QUERY_VALUE: &AsciiSet = &CONTROLS /// not silent. pub enum WsAuth { /// Append the credential as a query parameter on the connect URL. - /// Example: `wss://api.example.com/stream?authorization=`. + /// Example: ElevenLabs `tts/stream-input?authorization=`. QueryParam(String, AuthCredentialSource), /// Send the credential as an HTTP header on the WS upgrade request. - /// Example: a standard `X-Api-Key: ` header. + /// Example: standard `xi-api-key: ` on ElevenLabs convai. /// /// # Header-value prefixes (footgun) /// /// The source's resolved value becomes the *entire* header value. - /// Some APIs require `Authorization: Token ` — the literal word + /// Deepgram requires `Authorization: Token ` — the literal word /// `Token` is part of the value, NOT a scheme the library prepends. /// **Prefer the convenience constructors [`WsAuth::bearer`] / /// [`WsAuth::token`]** rather than baking the prefix into a literal @@ -73,34 +73,35 @@ pub enum WsAuth { /// to misspell. Header(String, AuthCredentialSource), /// Send multiple HTTP headers on the WS upgrade request. Use when the - /// API requires more than one header on the handshake (e.g. an auth - /// header plus an API-version header). Each pair is validated - /// against the WS-protocol reserved-header deny-list and each source - /// must resolve to a non-empty value. + /// API requires more than one header on the handshake — for example, + /// OpenAI Realtime needs both `Authorization: Bearer ` AND + /// `OpenAI-Beta: realtime=v1`. Each pair is validated against the + /// WS-protocol reserved-header deny-list and each source must + /// resolve to a non-empty value. Headers(Vec<(String, AuthCredentialSource)>), /// Merge the credential into the *first* outbound JSON frame as the - /// named field. Useful for APIs that authenticate via a "configure - /// session" message on the first text frame. + /// named field. Example: ElevenLabs TTS `stream-input` requires + /// `{"xi_api_key": "", ...}` as the first text frame. FirstMessage(String, AuthCredentialSource), - /// No auth (anonymous connection, or auth handled by the caller + /// No auth (anonymous connection, or auth handled by the customer /// outside this module). None, } impl WsAuth { /// `Authorization: Bearer ` convenience. Prepends the literal - /// `Bearer ` to the resolved credential so callers cannot - /// accidentally double-prefix or omit it. Use for any RFC-6750 - /// bearer-token API. + /// `Bearer ` to the resolved credential, so customers cannot + /// accidentally double-prefix or omit it. Use for OpenAI Realtime + /// and any RFC-6750 bearer-token API. pub fn bearer(source: AuthCredentialSource) -> Self { WsAuth::Header("Authorization".into(), prefix_source(source, "Bearer ")) } /// `Authorization: Token ` convenience. Prepends the literal - /// `Token ` to the resolved credential. Use for APIs that treat the - /// word `Token` as part of the value (not a scheme tungstenite - /// prepends) — callers that miss this footgun get a confusing 401 - /// from the upgrade. + /// `Token ` to the resolved credential. Use for Deepgram realtime — + /// Deepgram treats the word `Token` as part of the value, not a + /// scheme tungstenite prepends, so customers that miss this footgun + /// get a confusing 401 from the upgrade. pub fn token(source: AuthCredentialSource) -> Self { WsAuth::Header("Authorization".into(), prefix_source(source, "Token ")) } @@ -406,16 +407,16 @@ mod tests { #[test] fn headers_variant_emits_all_pairs_in_order() { - let mut url = "wss://api.example.com/v1/realtime?model=test".to_string(); + let mut url = "wss://api.openai.com/v1/realtime?model=gpt-4o".to_string(); let mut headers = Vec::new(); WsAuth::Headers(vec![ ( "Authorization".into(), - literal("Bearer sk-test"), + literal("Bearer sk-openai-test"), ), ( - "X-Api-Version".into(), - literal("v1"), + "OpenAI-Beta".into(), + literal("realtime=v1"), ), ]) .apply_to_url_and_headers(&mut url, &mut headers) @@ -423,12 +424,12 @@ mod tests { assert_eq!( headers, vec![ - ("Authorization".to_string(), "Bearer sk-test".to_string()), - ("X-Api-Version".to_string(), "v1".to_string()), + ("Authorization".to_string(), "Bearer sk-openai-test".to_string()), + ("OpenAI-Beta".to_string(), "realtime=v1".to_string()), ] ); // URL is unchanged. - assert_eq!(url, "wss://api.example.com/v1/realtime?model=test"); + assert_eq!(url, "wss://api.openai.com/v1/realtime?model=gpt-4o"); } #[test] @@ -450,31 +451,31 @@ mod tests { let mut headers = Vec::new(); let err = WsAuth::Headers(vec![ ("Authorization".into(), literal("Bearer xyz")), - ("X-Api-Version".into(), literal("")), + ("OpenAI-Beta".into(), literal("")), ]) .apply_to_url_and_headers(&mut url, &mut headers) .expect_err("missing cred should error"); assert!(matches!(err, CliError::Auth(_))); // Auth-error message names the missing header. - assert!(err.to_string().contains("X-Api-Version")); + assert!(err.to_string().contains("OpenAI-Beta")); } #[test] fn bearer_helper_prepends_literal_bearer_space() { - let mut url = "wss://api.example.com/v1/realtime".to_string(); + let mut url = "wss://api.openai.com/v1/realtime".to_string(); let mut headers = Vec::new(); - WsAuth::bearer(literal("sk-test")) + WsAuth::bearer(literal("sk-openai-test")) .apply_to_url_and_headers(&mut url, &mut headers) .unwrap(); assert_eq!( headers, - vec![("Authorization".to_string(), "Bearer sk-test".to_string())] + vec![("Authorization".to_string(), "Bearer sk-openai-test".to_string())] ); } #[test] fn token_helper_prepends_literal_token_space() { - let mut url = "wss://api.example.com/v1/listen".to_string(); + let mut url = "wss://api.deepgram.com/v1/listen".to_string(); let mut headers = Vec::new(); WsAuth::token(literal("dg_secret")) .apply_to_url_and_headers(&mut url, &mut headers) diff --git a/generators/cli/sdk/src/websocket/client.rs b/generators/cli/sdk/src/websocket/client.rs index 0823a541ae36..fa6a97088554 100644 --- a/generators/cli/sdk/src/websocket/client.rs +++ b/generators/cli/sdk/src/websocket/client.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; +use secrecy::ExposeSecret; use serde_json::Value; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::sync::mpsc; @@ -15,6 +16,7 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message}; +use crate::auth::AuthCredentialSource; use crate::error::CliError; use crate::formatter::OutputPipeline; use crate::http::HttpConfig; @@ -31,8 +33,8 @@ use super::error::{classify_close_frame, map_handshake_error, map_stream_error}; /// Returning `None` lets the inbound flow through to /// [`OutputPipeline::emit`]. /// -/// Write your own closure for app-level ping/pong or any other -/// inbound-frame responder pattern your API requires. +/// Ship via [`elevenlabs_convai_ping_pong`] for the canonical ElevenLabs +/// shape; write your own closure for Deepgram / AssemblyAI / OpenAI. /// /// # Stateful autoresponders /// @@ -78,13 +80,14 @@ pub struct WsConfig { /// is non-JSON. pub stdin_validate_json: bool, /// JSON keys to recursively elide from each inbound frame before - /// emitting. Use to strip base64 audio blobs that would otherwise - /// flood a terminal. + /// emitting. Use `vec!["audio_base_64".into()]` for ElevenLabs to + /// strip the base64 audio blobs that would otherwise flood a terminal. pub strip_audio_keys: Vec, /// Hint string woven into mid-stream / abnormal-close error messages. - /// Defaults to a generic "check auth, network, keepalive/timeout" - /// nudge; override when wiring an API with a more specific common - /// failure mode. + /// The default points at the ElevenLabs missed-pong pattern; override + /// when wiring an API with a different common failure mode (e.g. + /// Deepgram: "check KeepAlive cadence and audio format/encoding"; + /// OpenAI Realtime: "session may have hit the 30-minute cap"). pub abnormal_close_hint: String, } @@ -105,6 +108,122 @@ impl WsConfig { } } + // -------- Per-API constructors ----------------------------------------- + // + // These bake in the right auth shape, autoresponder, audio-strip keys, + // and abnormal-close hint for each supported API. They're plain + // convenience — every field stays `pub`, so power users can + // post-mutate. Adding a constructor for a new API costs ~10 lines. + + /// Config preset for ElevenLabs conversational AI + /// (`wss://api.elevenlabs.io/v1/convai/conversation?agent_id=...`). + /// + /// Bakes in: + /// - `xi-api-key: ` header auth + /// - the canonical app-level ping/pong [`elevenlabs_convai_ping_pong`] + /// autoresponder + /// - `strip_audio_keys = ["audio_base_64"]` (terminal-friendly) + /// - the ElevenLabs-flavoured `abnormal_close_hint` + pub fn elevenlabs_convai( + url: impl Into, + api_key: AuthCredentialSource, + ) -> Self { + let mut cfg = WsConfig::new(url); + cfg.auth = WsAuth::Header("xi-api-key".into(), api_key); + cfg.auto_responder = Some(elevenlabs_convai_ping_pong()); + cfg.strip_audio_keys = vec!["audio_base_64".into()]; + cfg.abnormal_close_hint = super::error::ELEVENLABS_CLOSE_HINT.to_string(); + cfg + } + + /// Config preset for ElevenLabs TTS stream-input + /// (`wss://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream-input?...`). + /// + /// Bakes in: + /// - `WsAuth::FirstMessage("xi_api_key", ...)` — merged into the BOS frame + /// - `strip_audio_keys = ["audio"]` (TTS-shaped audio field) + /// - the ElevenLabs-flavoured `abnormal_close_hint` + pub fn elevenlabs_tts(url: impl Into, api_key: AuthCredentialSource) -> Self { + let mut cfg = WsConfig::new(url); + cfg.auth = WsAuth::FirstMessage("xi_api_key".into(), api_key); + cfg.strip_audio_keys = vec!["audio".into()]; + cfg.abnormal_close_hint = super::error::ELEVENLABS_CLOSE_HINT.to_string(); + cfg + } + + /// Config preset for OpenAI Realtime + /// (`wss://api.openai.com/v1/realtime?model=...`). + /// + /// Bakes in: + /// - both required headers: `Authorization: Bearer ` AND + /// `OpenAI-Beta: realtime=v1` (via [`WsAuth::Headers`]) + /// - `strip_audio_keys = ["delta"]` — base64 audio lives under + /// `response.audio.delta` and `response.audio.done` + /// - the OpenAI-Realtime-flavoured `abnormal_close_hint` (calls out + /// the 30-minute session cap as the most common abnormal close) + pub fn openai_realtime( + url: impl Into, + api_key: AuthCredentialSource, + ) -> Self { + let mut cfg = WsConfig::new(url); + cfg.auth = WsAuth::Headers(vec![ + ( + "Authorization".into(), + // Prefix-prepending closure: WsAuth::bearer's helper + // lives in auth.rs but we recreate the moral equivalent + // here so the constructor is a single-call surface. + AuthCredentialSource::closure(move || { + api_key.resolve().map(|s| { + format!("Bearer {}", s.expose_secret()) + }) + }), + ), + ( + "OpenAI-Beta".into(), + AuthCredentialSource::literal("realtime=v1"), + ), + ]); + cfg.strip_audio_keys = vec!["delta".into()]; + cfg.abnormal_close_hint = super::error::OPENAI_REALTIME_CLOSE_HINT.to_string(); + cfg + } + + /// Config preset for Deepgram realtime listen + /// (`wss://api.deepgram.com/v1/listen?encoding=...&sample_rate=...`). + /// + /// Bakes in: + /// - `Authorization: Token ` (via [`WsAuth::token`]) + /// - the Deepgram-flavoured `abnormal_close_hint` + /// + /// Customers using this preset typically drive `send_binary(...)` + /// from their own audio-capture loop (cpal mic, file reader) and + /// send `{"type":"KeepAlive"}` text frames on a 3-8s timer. + pub fn deepgram_listen( + url: impl Into, + api_key: AuthCredentialSource, + ) -> Self { + let mut cfg = WsConfig::new(url); + cfg.auth = WsAuth::token(api_key); + cfg.abnormal_close_hint = super::error::DEEPGRAM_CLOSE_HINT.to_string(); + cfg + } + + /// Config preset for AssemblyAI v3 Universal-Streaming + /// (`wss://streaming.assemblyai.com/v3/ws?sample_rate=...&format_turns=...`). + /// + /// Bakes in: + /// - `Authorization: ` (raw — NO `Bearer ` / `Token ` prefix, + /// per AssemblyAI's v3 spec) + /// - the AssemblyAI-flavoured `abnormal_close_hint` + pub fn assemblyai_v3( + url: impl Into, + api_key: AuthCredentialSource, + ) -> Self { + let mut cfg = WsConfig::new(url); + cfg.auth = WsAuth::Header("Authorization".into(), api_key); + cfg.abnormal_close_hint = super::error::ASSEMBLYAI_CLOSE_HINT.to_string(); + cfg + } } /// A connected WS client ready to send and receive frames. @@ -226,11 +345,11 @@ impl WebSocketClient { /// Send raw bytes as a WS binary frame. /// - /// Required by APIs that ship PCM audio (or any other binary payload) - /// on the wire. Callers typically drive this from their own - /// audio-capture loop (`cpal` mic, file reader, etc.) rather than from - /// the stdin path — stdin forwarding stays JSON-text only in v1 - /// (see ADR-0002 follow-ups). + /// Required by APIs that ship PCM audio on the wire (Deepgram realtime, + /// AssemblyAI v3 Universal-Streaming). Customers typically call this + /// from their own audio-capture loop (`cpal` mic, file reader, etc.) + /// rather than from the stdin path — stdin forwarding stays JSON-text + /// only in v1 (see ADR-0002 follow-ups). /// /// # `WsAuth::FirstMessage` interaction /// @@ -283,9 +402,9 @@ impl WebSocketClient { let abnormal_hint = config.abnormal_close_hint.clone(); // Keep the first-send bookkeeping live across the loop so the // stdin branch can honor `WsAuth::FirstMessage` — without this, - // a caller combining `stdin_input = true` with `FirstMessage` + // a customer combining `stdin_input = true` with `FirstMessage` // auth would have the auth field silently dropped from the first - // outbound frame. + // outbound frame. (Bug surfaced by Devin Review on PR #53.) let mut first_send_done = first_send_done; // Bounded channel: stdin reader → recv loop. Bound is 64; when @@ -494,10 +613,12 @@ async fn handle_inbound( FrameDisposition::Continue } Some(Ok(Message::Binary(b))) => { - // v1: inbound binary frames are not emitted to stdout (most - // streaming APIs send JSON inbound and only accept binary - // outbound). Warn visibly so callers hitting an API that - // *does* stream binary back know their stream produced + // v1: inbound binary frames are not emitted to stdout + // (ElevenLabs / OpenAI use JSON text; Deepgram / AssemblyAI + // send JSON inbound and only accept binary outbound). Warn + // visibly so a customer hitting an API that *does* stream + // binary back (some Deepgram protobuf configs, some OpenAI + // tool-call audio paths) knows their stream produced // unprintable bytes — silence would look like a hung pipe. eprintln!( "warning: dropped {}-byte inbound WebSocket binary frame \ @@ -630,10 +751,75 @@ fn truncate(s: &str, max: usize) -> String { } } +/// Canonical ElevenLabs convai/TTS ping/pong autoresponder. +/// +/// Matches inbound frames of the shape +/// `{"type":"ping","ping_event":{"event_id":}}` and replies with +/// `{"type":"pong","event_id":}`. Other inbound frames are +/// passed through to the output pipeline. +/// +/// Spec'd at `fern/apis/convai/asyncapi.yml:162-175`. Required: missing +/// pong replies trip a 20-second inactivity timeout server-side. +pub fn elevenlabs_convai_ping_pong() -> AutoResponder { + Arc::new(|frame: &Value| -> Option { + if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { + return None; + } + // ElevenLabs spec: `/ping_event/event_id` is an integer. If we + // see a ping without it, *warn loudly* — falling through to + // emit-and-no-pong would silently let the 20-second server + // inactivity timeout fire, producing a misleading "missed + // ping/pong" abnormal-close error whose root cause is a parser + // miss, not a missing reply. + match frame.pointer("/ping_event/event_id").and_then(|v| v.as_i64()) { + Some(event_id) => Some(serde_json::json!({ + "type": "pong", + "event_id": event_id, + })), + None => { + eprintln!( + "warning: ElevenLabs ping frame has no integer \ + `ping_event.event_id` — cannot construct a valid \ + pong; frame will be emitted instead. If the API \ + changed the ping shape, write a custom AutoResponder." + ); + None + } + } + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn elevenlabs_preset_matches_canonical_ping() { + let responder = elevenlabs_convai_ping_pong(); + let ping = serde_json::json!({ + "type": "ping", + "ping_event": {"event_id": 12345, "ping_ms": 50}, + }); + let pong = responder(&ping).expect("ping should be matched"); + assert_eq!(pong, serde_json::json!({"type": "pong", "event_id": 12345})); + } + + #[test] + fn elevenlabs_preset_ignores_non_ping() { + let responder = elevenlabs_convai_ping_pong(); + let other = serde_json::json!({"type": "agent_response", "text": "hello"}); + assert!(responder(&other).is_none()); + } + + #[test] + fn elevenlabs_preset_returns_none_when_event_id_missing() { + let responder = elevenlabs_convai_ping_pong(); + let malformed = serde_json::json!({"type": "ping"}); + // Without an event_id we can't construct a meaningful pong; fall + // through to the emit path rather than send garbage. + assert!(responder(&malformed).is_none()); + } + #[test] fn strip_keys_removes_top_level_and_nested() { let mut value = serde_json::json!({ diff --git a/generators/cli/sdk/src/websocket/error.rs b/generators/cli/sdk/src/websocket/error.rs index 8e6d5a317252..ee5232671daf 100644 --- a/generators/cli/sdk/src/websocket/error.rs +++ b/generators/cli/sdk/src/websocket/error.rs @@ -18,19 +18,41 @@ //! | local | unparseable JSON from server | `Other` | 5 | //! //! The abnormal-close hint nudges users toward the most common failure -//! mode: auth / network / app-level keepalive misses. +//! mode: missed pings under ElevenLabs' 20-second inactivity timeout. use tokio_tungstenite::tungstenite; use crate::error::CliError; /// Default hint appended to abnormal-close errors. API-neutral by -/// design — it's the message a user of *any* WS-using CLI should -/// understand. Override per-CLI by setting -/// [`super::WsConfig::abnormal_close_hint`] with API-specific guidance. +/// design — it's the message a customer of *any* WS-using CLI should +/// understand. Per-API constructors on [`super::WsConfig`] (e.g. +/// [`super::WsConfig::elevenlabs_convai`], [`super::WsConfig::deepgram_listen`]) +/// override this with API-specific guidance. pub const ABNORMAL_CLOSE_HINT: &str = "connection ended abnormally; check auth, network, and the API's keepalive/timeout requirements"; +/// ElevenLabs-flavoured hint: the most common ElevenLabs failure mode +/// is a missed app-level pong inside the 20-second inactivity window. +/// Used by [`super::WsConfig::elevenlabs_convai`] / +/// [`super::WsConfig::elevenlabs_tts`]. +pub const ELEVENLABS_CLOSE_HINT: &str = + "likely missed ping/pong reply; check your auto_responder config"; + +/// Deepgram-flavoured hint. +pub const DEEPGRAM_CLOSE_HINT: &str = + "check KeepAlive cadence (every 3-8s) and audio encoding/sample_rate"; + +/// OpenAI Realtime-flavoured hint. +pub const OPENAI_REALTIME_CLOSE_HINT: &str = + "session may have hit the 30-minute cap or the model rejected the config; \ + see OpenAI Realtime docs"; + +/// AssemblyAI-flavoured hint. +pub const ASSEMBLYAI_CLOSE_HINT: &str = + "check audio encoding (PCM16 expected) and that the session hasn't \ + exceeded its idle/max duration"; + /// Map a `tungstenite::Error` raised during the handshake phase to a /// [`CliError`] following the matrix above. Public so an external caller /// implementing its own handshake wrapper (e.g. for unit-testing the @@ -106,16 +128,16 @@ pub(crate) fn map_stream_error(err: tungstenite::Error, hint: &str) -> CliError /// Returns `Ok(())` for **success-shaped** closures: /// - `1000 Normal Closure` — protocol-correct end-of-session. /// - `1001 Going Away` — peer is leaving (page navigation, server -/// shutdown, *or* session-cap expiry like a long-running session -/// hitting a server-side hard limit). For long-running sessions this -/// is the polite way to say "we're done"; treating it as an error -/// would cause shell pipelines to spuriously fail on a clean -/// end-of-session. +/// shutdown, *or* session-cap expiry like OpenAI Realtime's 30-minute +/// hard limit). For long-running sessions this is the polite way to +/// say "we're done"; treating it as an error would cause shell +/// pipelines to spuriously fail on a clean end-of-session. /// /// Returns `Err` for everything else, with `hint` woven into the message /// when supplied. `hint` is what the user should investigate; pass -/// [`ABNORMAL_CLOSE_HINT`] for the generic default, or supply an -/// API-specific string (see [`super::WsConfig::abnormal_close_hint`]). +/// [`ABNORMAL_CLOSE_HINT`] for the canonical ElevenLabs missed-pong +/// nudge, or supply an API-specific string (see +/// [`super::WsConfig::abnormal_close_hint`]). pub(crate) fn classify_close_frame( frame: Option<&tungstenite::protocol::CloseFrame<'_>>, hint: &str, @@ -193,9 +215,8 @@ mod tests { #[test] fn close_1001_going_away_is_ok() { // 1001 = peer is leaving (page nav, server shutdown, session-cap - // expiry). Treated as a clean end-of-session for long-running - // sessions that hit a server-side hard limit and similar - // "polite hangup" patterns. + // expiry). Treated as a clean end-of-session per OpenAI Realtime + // 30-minute hard limit and similar "polite hangup" patterns. assert!(classify_close_frame(Some(&frame(1001, "session cap")), ABNORMAL_CLOSE_HINT).is_ok()); } @@ -221,7 +242,7 @@ mod tests { #[test] fn custom_hint_replaces_default_in_message() { - let custom = "custom hint: check KeepAlive cadence + audio format"; + let custom = "Deepgram check: KeepAlive cadence + audio format"; let err = classify_close_frame(Some(&frame(1006, "")), custom).unwrap_err(); assert!(err.to_string().contains(custom)); assert!(!err.to_string().contains(ABNORMAL_CLOSE_HINT), diff --git a/generators/cli/sdk/src/websocket/mod.rs b/generators/cli/sdk/src/websocket/mod.rs index 090000eef2ac..93cbadf328a4 100644 --- a/generators/cli/sdk/src/websocket/mod.rs +++ b/generators/cli/sdk/src/websocket/mod.rs @@ -3,10 +3,10 @@ //! WebSocket bidirectional client. //! //! Used by custom commands that need to graft a long-lived bidirectional -//! connection onto the CLI (realtime streaming, conversational APIs, -//! etc.). The recv loop emits each inbound JSON frame through -//! [`crate::formatter::OutputPipeline`] so format / color / future -//! jq/fields/template flags compose for free. +//! connection onto the CLI (ElevenLabs convai/TTS, Deepgram realtime, +//! AssemblyAI realtime, OpenAI Realtime). The recv loop emits each inbound +//! JSON frame through [`crate::formatter::OutputPipeline`] so format / +//! color / future jq/fields/template flags compose for free. //! //! # Composition with [`AppContext`](crate::openapi::AppContext) //! @@ -44,5 +44,7 @@ mod client; mod error; pub use auth::WsAuth; -pub use client::{AutoResponder, WebSocketClient, WsConfig}; +pub use client::{ + elevenlabs_convai_ping_pong, AutoResponder, WebSocketClient, WsConfig, +}; pub use error::map_handshake_error; diff --git a/generators/cli/sdk/tests/auth_routing_wire.rs b/generators/cli/sdk/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/generators/cli/sdk/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/generators/cli/sdk/tests/common/mod.rs b/generators/cli/sdk/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/generators/cli/sdk/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/generators/cli/sdk/tests/extension_surface_behavior.rs b/generators/cli/sdk/tests/extension_surface_behavior.rs deleted file mode 100644 index bcaea1188e5a..000000000000 --- a/generators/cli/sdk/tests/extension_surface_behavior.rs +++ /dev/null @@ -1,840 +0,0 @@ -//! Behavior tests for the extension surface (`app::CliApp` + `Binding`). -//! -//! Every test builds a `CliApp` with a mock binding, registers hooks or -//! Tier 1 methods, invokes the CLI via `try_run_from_with_output(argv, &mut buf)`, -//! and asserts on **observable output** (captured buffer, exit code). Tests that -//! only check builder state do not belong here. -//! -//! See -//! for why the single-binding fast path was removed and these behavior -//! tests are required. - -use std::sync::{Arc, Mutex}; - -use serde_json::{json, Value}; - -use fern_cli_sdk::app::CliApp; -use fern_cli_sdk::binding::{Binding, BoxFuture, DispatchResult}; -use fern_cli_sdk::error::CliError; - -// ── Mock Binding ──────────────────────────────────────────────────── - -/// A minimal `Binding` impl that returns a controlled response. -/// Used to test CliApp-scope hooks and Tier 1 methods in isolation -/// without needing a real spec or wiremock server. -struct MockBinding { - response: Value, - error: Option, - dispatched: Arc>>>, -} - -impl MockBinding { - fn new(response: Value) -> Self { - Self { - response, - error: None, - dispatched: Arc::new(Mutex::new(Vec::new())), - } - } - - fn with_error(error: CliError) -> Self { - Self { - response: Value::Null, - error: Some(error), - dispatched: Arc::new(Mutex::new(Vec::new())), - } - } - - fn dispatched_log(&self) -> Arc>>> { - Arc::clone(&self.dispatched) - } -} - -impl Binding for MockBinding { - fn name(&self) -> &str { - "mock" - } - - fn set_cli_name(&mut self, _name: &str) {} - - fn build_command(&self) -> Result { - Ok(clap::Command::new("mock") - .subcommand( - clap::Command::new("users") - .subcommand(clap::Command::new("get")) - .subcommand(clap::Command::new("list")) - .subcommand(clap::Command::new("create")), - ) - .subcommand( - clap::Command::new("files") - .subcommand(clap::Command::new("get")) - .subcommand(clap::Command::new("upload")), - )) - } - - fn dispatch<'a>( - &'a self, - _root_matches: &'a clap::ArgMatches, - _sub_matches: &'a clap::ArgMatches, - op_path: &'a [String], - ) -> BoxFuture<'a, Result> { - let path = op_path.to_vec(); - let log = Arc::clone(&self.dispatched); - let response = self.response.clone(); - let error = self.error.as_ref().map(|e| e.duplicate()); - - Box::pin(async move { - log.lock().unwrap().push(path); - if let Some(err) = error { - return Err(err); - } - Ok(DispatchResult::Value(response)) - }) - } -} - -/// Run a `CliApp` via `try_run_from_with_output` and return (captured stdout, exit code). -fn run_app(app: CliApp, args: I) -> (String, i32) -where - I: IntoIterator, - T: Into, -{ - let mut buf = Vec::new(); - let code = app.try_run_from_with_output(args, &mut buf); - let output = String::from_utf8(buf).expect("output should be valid UTF-8"); - (output, code) -} - -// ── Tier 2: transform_response ────────────────────────────────────── - -#[test] -fn transform_response_strips_data_wrapper() { - let mock = MockBinding::new(json!({ - "data": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], - "meta": {"total": 2} - })); - - let app = CliApp::new("test-cli") - .binding(mock) - .transform_response(&["**"], |value, _path| async move { - if let Some(data) = value.get("data").cloned() { - Ok(data) - } else { - Ok(value) - } - }); - - let (stdout, exit_code) = run_app(app, ["test-cli", "users", "list"]); - - assert_eq!(exit_code, 0, "expected success, got exit code {exit_code}"); - let parsed: Value = serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON"); - assert!(parsed.is_array(), "expected array, got: {parsed}"); - assert_eq!(parsed.as_array().unwrap().len(), 2); - assert_eq!(parsed[0]["name"], "Alice"); -} - -#[test] -fn transform_response_only_fires_for_matching_path() { - let mock = MockBinding::new(json!({"original": true})); - - let app = CliApp::new("test-cli") - .binding(mock) - .transform_response(&["files", "**"], |_value, _path| async move { - Ok(json!({"transformed": true})) - }); - - let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_eq!(exit_code, 0); - let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(parsed["original"], true, "hook should not fire for users/get"); -} - -#[test] -fn transform_response_chain_applies_in_order() { - let mock = MockBinding::new(json!({"count": 1})); - - let app = CliApp::new("test-cli") - .binding(mock) - .transform_response(&["**"], |mut value, _path| async move { - if let Some(n) = value.get("count").and_then(|v| v.as_i64()) { - value["count"] = json!(n * 2); - } - Ok(value) - }) - .transform_response(&["**"], |mut value, _path| async move { - if let Some(n) = value.get("count").and_then(|v| v.as_i64()) { - value["count"] = json!(n + 10); - } - Ok(value) - }); - - let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_eq!(exit_code, 0); - let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); - // 1 * 2 = 2, then 2 + 10 = 12 - assert_eq!(parsed["count"], 12, "hooks should chain: 1 → 2 → 12"); -} - -// ── Tier 2: recover_error ─────────────────────────────────────────── - -#[test] -fn recover_error_converts_404_to_success() { - let mock = MockBinding::with_error(CliError::Api { - code: 404, - message: "Not Found".to_string(), - reason: "not_found".to_string(), - }); - - let app = CliApp::new("test-cli") - .binding(mock) - .recover_error(&["**"], |_err, _path| async move { - Ok(Some(json!({"deleted": true, "status": "already_gone"}))) - }); - - let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_eq!(exit_code, 0, "recover_error should produce exit code 0"); - let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(parsed["deleted"], true); - assert_eq!(parsed["status"], "already_gone"); -} - -#[test] -fn recover_error_none_lets_error_propagate() { - let mock = MockBinding::with_error(CliError::Api { - code: 500, - message: "Internal Server Error".to_string(), - reason: "server_error".to_string(), - }); - - let app = CliApp::new("test-cli") - .binding(mock) - .recover_error(&["**"], |_err, _path| async move { - Ok(None) - }); - - let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_ne!(exit_code, 0, "error should propagate when hook returns None"); -} - -#[test] -fn recover_error_only_fires_for_matching_path() { - let mock = MockBinding::with_error(CliError::Api { - code: 404, - message: "Not Found".to_string(), - reason: "not_found".to_string(), - }); - - let app = CliApp::new("test-cli") - .binding(mock) - .recover_error(&["files", "**"], |_err, _path| async move { - Ok(Some(json!({"recovered": true}))) - }); - - let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_ne!(exit_code, 0, "hook should not fire for users/get path"); -} - -// ── Tier 1: alias ─────────────────────────────────────────────────── - -#[test] -fn alias_produces_same_output_as_canonical_name() { - let response = json!({"id": 42, "name": "test-user"}); - - // Run with alias. - let mock1 = MockBinding::new(response.clone()); - let app1 = CliApp::new("test-cli") - .binding(mock1) - .alias(&["users"], "u"); - - let (stdout_alias, code_alias) = run_app(app1, ["test-cli", "u", "get"]); - - // Run with canonical name. - let mock2 = MockBinding::new(response); - let app2 = CliApp::new("test-cli") - .binding(mock2) - .alias(&["users"], "u"); - - let (stdout_canonical, code_canonical) = run_app(app2, ["test-cli", "users", "get"]); - - assert_eq!(code_alias, 0, "alias should work: exit code {code_alias}"); - assert_eq!(code_canonical, 0); - assert_eq!( - stdout_alias.trim(), - stdout_canonical.trim(), - "alias output should match canonical output" - ); - let parsed: Value = serde_json::from_str(stdout_alias.trim()).unwrap(); - assert_eq!(parsed["name"], "test-user"); -} - -// ── Tier 1: hide ──────────────────────────────────────────────────── - -#[test] -fn hide_command_still_executes() { - let mock = MockBinding::new(json!({"hidden_result": true})); - - let app = CliApp::new("test-cli") - .binding(mock) - .hide(&["files"]); - - let (stdout, exit_code) = run_app(app, ["test-cli", "files", "get"]); - assert_eq!(exit_code, 0, "hidden command should still execute"); - let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(parsed["hidden_result"], true); -} - -// ── Tier 1: stability ─────────────────────────────────────────────── - -#[test] -fn stability_badge_appears_in_help() { - use fern_cli_sdk::stability::Stability; - - let mock = MockBinding::new(json!({})); - let app = CliApp::new("test-cli") - .binding(mock) - .stability(&["users"], Stability::Beta); - - let (help_stdout, exit_code) = run_app(app, ["test-cli", "--help"]); - - assert_eq!(exit_code, 0, "help should produce exit code 0"); - assert!( - help_stdout.contains("[beta]"), - "beta badge should appear in help: {help_stdout}" - ); -} - -// ── Tier 1: deprecate ─────────────────────────────────────────────── - -#[test] -fn deprecate_marks_command_in_help() { - let mock = MockBinding::new(json!({})); - let app = CliApp::new("test-cli") - .binding(mock) - .deprecate(&["files"], "Use 'documents' instead"); - - let (help_stdout, exit_code) = run_app(app, ["test-cli", "--help"]); - - assert_eq!(exit_code, 0); - assert!( - help_stdout.contains("[deprecated]"), - "deprecated badge should appear: {help_stdout}" - ); -} - -// ── No single-binding fast path ───────────────────────────────────── - -#[test] -fn single_binding_still_runs_full_pipeline() { - // This is the critical test: with only one binding, hooks MUST fire. - // PR #62's bug was that single-binding CLIs bypassed hooks. - let mock = MockBinding::new(json!({"raw": "untouched"})); - let log = mock.dispatched_log(); - - let app = CliApp::new("test-cli") - .binding(mock) - .transform_response(&["**"], |_value, _path| async move { - Ok(json!({"hook_fired": true})) - }); - - let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_eq!(exit_code, 0); - let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!( - parsed["hook_fired"], true, - "transform_response MUST fire even with a single binding" - ); - let dispatches = log.lock().unwrap(); - assert_eq!(dispatches.len(), 1); - assert_eq!(dispatches[0], vec!["users", "get"]); -} - -#[test] -fn single_binding_recover_error_fires() { - let mock = MockBinding::with_error(CliError::Api { - code: 503, - message: "Service Unavailable".to_string(), - reason: "unavailable".to_string(), - }); - - let app = CliApp::new("test-cli") - .binding(mock) - .recover_error(&["**"], |_err, _path| async move { - Ok(Some(json!({"fallback": true}))) - }); - - let (stdout, exit_code) = run_app(app, ["test-cli", "users", "list"]); - - assert_eq!(exit_code, 0, "recover_error must fire for single binding"); - let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(parsed["fallback"], true); -} - -// ── Dispatch recording ────────────────────────────────────────────── - -#[test] -fn dispatch_records_correct_op_path() { - let mock = MockBinding::new(json!({})); - let log = mock.dispatched_log(); - - let app = CliApp::new("test-cli").binding(mock); - - let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "create"]); - - assert_eq!(exit_code, 0); - let dispatches = log.lock().unwrap(); - assert_eq!(dispatches.len(), 1); - assert_eq!(dispatches[0], vec!["users", "create"]); -} - -// ── Multi-binding dispatch ─────────────────────────────────────────── - -/// A second binding providing a distinct subtree (`orders`). -struct OrdersBinding { - response: Value, -} - -impl OrdersBinding { - fn new(response: Value) -> Self { - Self { response } - } -} - -impl Binding for OrdersBinding { - fn name(&self) -> &str { - "orders" - } - - fn set_cli_name(&mut self, _name: &str) {} - - fn build_command(&self) -> Result { - Ok(clap::Command::new("orders") - .subcommand( - clap::Command::new("orders") - .subcommand(clap::Command::new("get")) - .subcommand(clap::Command::new("list")), - )) - } - - fn dispatch<'a>( - &'a self, - _root_matches: &'a clap::ArgMatches, - _sub_matches: &'a clap::ArgMatches, - _op_path: &'a [String], - ) -> BoxFuture<'a, Result> { - let response = self.response.clone(); - Box::pin(async move { Ok(DispatchResult::Value(response)) }) - } -} - -#[test] -fn multi_binding_routes_to_correct_binding() { - let mock_users = MockBinding::new(json!({"source": "users-binding"})); - let mock_orders = OrdersBinding::new(json!({"source": "orders-binding"})); - let user_log = mock_users.dispatched_log(); - - let app = CliApp::new("test-cli") - .binding(mock_users) - .binding(mock_orders); - - // Route to users binding. - let (stdout, code) = run_app(app, ["test-cli", "users", "get"]); - assert_eq!(code, 0); - let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(parsed["source"], "users-binding"); - let dispatches = user_log.lock().unwrap(); - assert_eq!(dispatches.len(), 1); -} - -#[test] -fn multi_binding_hooks_fire_for_both_bindings() { - let mock_users = MockBinding::new(json!({"raw": true})); - let mock_orders = OrdersBinding::new(json!({"raw": true})); - - let app = CliApp::new("test-cli") - .binding(mock_users) - .binding(mock_orders) - .transform_response(&["**"], |_v, _p| async move { - Ok(json!({"hooked": true})) - }); - - let (stdout, code) = run_app(app, ["test-cli", "users", "get"]); - assert_eq!(code, 0); - let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(parsed["hooked"], true, "hook should fire for users binding"); -} - -// ── recover_error: decline preserves original error ───────────────── - -#[test] -fn recover_error_decline_preserves_original_for_next_hook() { - let mock = MockBinding::with_error(CliError::Api { - code: 404, - message: "Not Found".to_string(), - reason: "not_found".to_string(), - }); - - let app = CliApp::new("test-cli") - .binding(mock) - // First hook declines. - .recover_error(&["**"], |_err, _path| async move { Ok(None) }) - // Second hook recovers — should see the original 404 error, - // not a synthetic fallback. - .recover_error(&["**"], |err, _path| async move { - let msg = format!("{err}"); - if msg.contains("Not Found") { - Ok(Some(json!({"recovered_from_original": true}))) - } else { - Ok(None) - } - }); - - let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_eq!(exit_code, 0, "second hook should recover"); - let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!( - parsed["recovered_from_original"], true, - "second hook should see the original error, not a fallback" - ); -} - -// ── Tier 1 ops preserve parent settings ───────────────────────────── - -#[test] -fn tier1_ops_preserve_arg_required_else_help() { - // Verify that applying a Tier 1 op (alias) via mut_subcommand - // doesn't lose the arg_required_else_help setting on the root. - let mock = MockBinding::new(json!({"ok": true})); - - let app = CliApp::new("test-cli") - .binding(mock) - .alias(&["users"], "u"); - - // Invoke without a subcommand — should show help (exit 0) rather - // than silently succeeding or crashing. - let (stdout, exit_code) = run_app(app, ["test-cli"]); - assert_eq!(exit_code, 0, "should show help for missing subcommand"); - assert!( - stdout.contains("Usage") || stdout.contains("usage"), - "output should contain usage text: {stdout}" - ); -} - -// ── Multi-binding context merging ──────────────────────────────────── - -/// A mergeable context type for testing `merge_binding_context`. -struct MergeableContext { - entries: Vec, -} - -/// A binding whose `merge_binding_context` merges entries from multiple -/// bindings into a single `MergeableContext`, mirroring how -/// `OpenApiBinding` merges `AppContext` entries. -struct MergeableMockBinding { - name: &'static str, - entry: String, - commands: clap::Command, -} - -impl MergeableMockBinding { - fn new(name: &'static str, entry: &str, commands: clap::Command) -> Self { - Self { - name, - entry: entry.to_string(), - commands, - } - } -} - -impl Binding for MergeableMockBinding { - fn name(&self) -> &str { - self.name - } - fn set_cli_name(&mut self, _name: &str) {} - - fn build_command(&self) -> Result { - Ok(self.commands.clone()) - } - - fn dispatch<'a>( - &'a self, - _root_matches: &'a clap::ArgMatches, - _sub_matches: &'a clap::ArgMatches, - _op_path: &'a [String], - ) -> BoxFuture<'a, Result> { - Box::pin(async { Ok(DispatchResult::Value(json!({}))) }) - } - - fn merge_binding_context( - &self, - _matches: &clap::ArgMatches, - existing: Option>, - ) -> Result>, CliError> { - match existing { - Some(ctx_box) => match ctx_box.downcast::() { - Ok(mut ctx) => { - ctx.entries.push(self.entry.clone()); - Ok(Some(ctx as Box)) - } - Err(_) => Ok(Some(Box::new(MergeableContext { - entries: vec![self.entry.clone()], - }))), - }, - None => Ok(Some(Box::new(MergeableContext { - entries: vec![self.entry.clone()], - }))), - } - } -} - -#[test] -fn multi_binding_custom_command_receives_merged_context() { - let binding_a = MergeableMockBinding::new( - "alpha", - "alpha-entry", - clap::Command::new("alpha") - .subcommand(clap::Command::new("users").subcommand(clap::Command::new("get"))), - ); - let binding_b = MergeableMockBinding::new( - "beta", - "beta-entry", - clap::Command::new("beta") - .subcommand(clap::Command::new("orders").subcommand(clap::Command::new("list"))), - ); - - let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); - let captured_clone = Arc::clone(&captured); - - let app = CliApp::new("test-cli") - .binding(binding_a) - .binding(binding_b) - .command( - clap::Command::new("status"), - Box::new(move |_matches, ctx| { - let merged = ctx.downcast_ref::().ok_or_else(|| { - CliError::Validation("expected MergeableContext".into()) - })?; - *captured_clone.lock().unwrap() = merged.entries.clone(); - Ok(()) - }), - ); - - let (_stdout, exit_code) = run_app(app, ["test-cli", "status"]); - assert_eq!(exit_code, 0, "custom command should succeed"); - let entries = captured.lock().unwrap(); - assert_eq!(entries.len(), 2, "should have entries from both bindings: {entries:?}"); - assert!(entries.contains(&"alpha-entry".to_string()), "missing alpha: {entries:?}"); - assert!(entries.contains(&"beta-entry".to_string()), "missing beta: {entries:?}"); -} - -// ── Hook pattern validation ───────────────────────────────────────── - -#[test] -fn hook_pattern_matching_no_operations_hard_fails() { - let mock = MockBinding::new(json!({"ok": true})); - - // "user" is a typo — the real command is "users" - let app = CliApp::new("test-cli") - .binding(mock) - .transform_response(&["user", "get"], |v, _p| async move { Ok(v) }); - - let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - assert_ne!(exit_code, 0, "typo in hook pattern should hard fail"); -} - -#[test] -fn hook_pattern_matching_valid_operations_succeeds() { - let mock = MockBinding::new(json!({"ok": true})); - - let app = CliApp::new("test-cli") - .binding(mock) - .transform_response(&["users", "get"], |v, _p| async move { Ok(v) }); - - let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - assert_eq!(exit_code, 0, "valid hook pattern should pass validation"); -} - -#[test] -fn hook_pattern_globstar_matches_existing_operations() { - let mock = MockBinding::new(json!({"ok": true})); - - let app = CliApp::new("test-cli") - .binding(mock) - .transform_response(&["users", "**"], |v, _p| async move { Ok(v) }); - - let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - assert_eq!(exit_code, 0, "globstar matching real operations should pass"); -} - -#[test] -fn recover_error_pattern_matching_no_operations_hard_fails() { - let mock = MockBinding::new(json!({"ok": true})); - - let app = CliApp::new("test-cli") - .binding(mock) - .recover_error(&["nonexistent", "path"], |e, _p| async move { Err(e) }); - - let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - assert_ne!(exit_code, 0, "typo in recover_error pattern should hard fail"); -} - -// ── No bindings → error ───────────────────────────────────────────── - -#[test] -fn no_bindings_returns_error() { - let app = CliApp::new("test-cli"); - - let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_ne!(exit_code, 0, "no bindings should produce an error"); -} - -// ── --format validation ───────────────────────────────────────────── - -#[test] -fn unknown_format_returns_validation_error() { - let app = CliApp::new("test-cli") - .binding(MockBinding::new(json!({"ok": true}))); - - let (stdout, exit_code) = run_app(app, ["test-cli", "--format", "xml", "users", "get"]); - - assert_eq!(exit_code, 3, "unknown --format should exit with validation code: {stdout}"); - assert!( - stdout.contains("unknown output format"), - "error should mention the unknown format, got: {stdout}", - ); -} - -// ── Root Auth ─────────────────────────────────────────────────────── - -#[test] -fn root_auth_propagates_to_binding() { - use fern_cli_sdk::auth::BearerAuth; - - let app = CliApp::new("test-cli") - .auth(BearerAuth::new("bearerAuth").env("MY_TOKEN")) - .binding(MockBinding::new(json!({"ok": true}))); - - let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_eq!(exit_code, 0, "root auth + mock binding should succeed: {stdout}"); -} - -#[test] -fn root_auth_multiple_schemes() { - use fern_cli_sdk::auth::{ApiKeyAuth, BearerAuth}; - - let app = CliApp::new("test-cli") - .auth(BearerAuth::new("bearerAuth").env("TOKEN")) - .auth(ApiKeyAuth::new("apiKey").env("KEY")) - .binding(MockBinding::new(json!({"ok": true}))); - - let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_eq!(exit_code, 0, "multiple root auth schemes should work: {stdout}"); -} - -#[test] -fn root_auth_basic_scheme() { - use fern_cli_sdk::auth::BasicAuth; - - let app = CliApp::new("test-cli") - .auth(BasicAuth::new("httpBasic").username_env("USER").password_env("PASS")) - .binding(MockBinding::new(json!({"ok": true}))); - - let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); - - assert_eq!(exit_code, 0, "root basic auth should work: {stdout}"); -} - -#[test] -fn root_auth_validation_warns_on_partial_binding() { - use fern_cli_sdk::auth::BearerAuth; - use fern_cli_sdk::openapi::OpenApiBinding; - - // Minimal spec that declares `bearerAuth` in securitySchemes - let spec = r#" -openapi: "3.0.0" -info: - title: Test - version: "1.0" -paths: - /users: - get: - x-fern-sdk-group-name: users - x-fern-sdk-method-name: list - security: - - bearerAuth: [] - responses: - "200": - description: ok -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - apiKeyAuth: - type: apiKey - in: header - name: X-API-Key -"#; - - // Register only bearerAuth — apiKeyAuth is missing → should warn but - // still succeed (multi-spec binaries may intentionally bind only a - // subset of schemes). - let app = CliApp::new("test-cli") - .auth(BearerAuth::new("bearerAuth").env("TOKEN")) - .binding(OpenApiBinding::new().spec(spec)); - - let (_stdout, exit_code) = run_app(app, ["test-cli", "--help"]); - - assert_eq!(exit_code, 0, "partial auth binding should succeed (unbound schemes get a warning)"); -} - -#[test] -fn root_auth_validation_passes_when_all_schemes_registered() { - use fern_cli_sdk::auth::{ApiKeyAuth, BearerAuth}; - use fern_cli_sdk::openapi::OpenApiBinding; - - let spec = r#" -openapi: "3.0.0" -info: - title: Test - version: "1.0" -paths: - /users: - get: - x-fern-sdk-group-name: users - x-fern-sdk-method-name: list - security: - - bearerAuth: [] - responses: - "200": - description: ok -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - apiKeyAuth: - type: apiKey - in: header - name: X-API-Key -"#; - - // Register both schemes → validation passes - let app = CliApp::new("test-cli") - .auth(BearerAuth::new("bearerAuth").env("TOKEN")) - .auth(ApiKeyAuth::new("apiKeyAuth").env("KEY")) - .binding(OpenApiBinding::new().spec(spec)); - - let (stdout, exit_code) = run_app(app, ["test-cli", "--help"]); - - assert_eq!(exit_code, 0, "all schemes registered should pass: {stdout}"); -} diff --git a/generators/cli/sdk/tests/fixtures/openapi-fixture-mappings.json b/generators/cli/sdk/tests/fixtures/openapi-fixture-mappings.json deleted file mode 100644 index eabf0b83d94e..000000000000 --- a/generators/cli/sdk/tests/fixtures/openapi-fixture-mappings.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "mappings": [ - { - "request": { - "method": "GET", - "url": "/files/file-1", - "headers": { - "Authorization": {"matches": "Bearer .+"} - } - }, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "body": "{\"id\":\"file-1\",\"type\":\"file\",\"name\":\"sample.txt\"}" - } - }, - { - "request": { - "method": "GET", - "url": "/folders/folder-1", - "headers": { - "Authorization": {"matches": "Bearer .+"} - } - }, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "body": "{\"id\":\"folder-1\",\"type\":\"folder\",\"name\":\"my-docs\"}" - } - }, - { - "request": { - "method": "GET", - "url": "/users/me", - "headers": { - "Authorization": {"matches": "Bearer .+"} - } - }, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "body": "{\"id\":\"user-1\",\"type\":\"user\",\"name\":\"Test User\"}" - } - }, - { - "request": { - "method": "GET", - "url": "/users/user-1", - "headers": { - "Authorization": {"matches": "Bearer .+"} - } - }, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "body": "{\"id\":\"user-1\",\"type\":\"user\",\"name\":\"Test User\"}" - } - }, - { - "request": { - "method": "GET", - "url": "/users", - "headers": { - "Authorization": {"matches": "Bearer .+"} - } - }, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "body": "{\"total_count\":1,\"entries\":[{\"id\":\"user-1\",\"type\":\"user\",\"name\":\"Test User\"}]}" - } - }, - { - "request": { - "method": "GET", - "url": "/folders/folder-1/items", - "headers": { - "Authorization": {"matches": "Bearer .+"} - } - }, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "body": "{\"total_count\":1,\"entries\":[{\"id\":\"file-1\",\"type\":\"file\",\"name\":\"sample.txt\"}]}" - } - }, - { - "request": { - "method": "GET", - "url": "/reports", - "headers": { - "Authorization": {"matches": "Bearer .+"} - } - }, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "body": "{\"data\":[{\"id\":\"rpt-1\",\"name\":\"Q1\"},{\"id\":\"rpt-2\",\"name\":\"Q2\"}],\"meta\":{\"total\":2,\"page\":1}}" - } - }, - { - "request": { - "method": "GET", - "url": "/reports/stats", - "headers": { - "Authorization": {"matches": "Bearer .+"} - } - }, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "body": "{\"result\":{\"payload\":{\"value\":42,\"unit\":\"reports\"}},\"meta\":{\"server_time\":\"2024-01-01\"}}" - } - } - ] -} diff --git a/generators/cli/sdk/tests/lib_api.rs b/generators/cli/sdk/tests/lib_api.rs deleted file mode 100644 index 18af0f15c680..000000000000 --- a/generators/cli/sdk/tests/lib_api.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - fn custom_handler( - _args: &clap::ArgMatches, - _ctx: &fern_cli_sdk::openapi::AppContext, - ) -> Result<(), fern_cli_sdk::error::CliError> { - Ok(()) - } - - let app = fern_cli_sdk::app::CliApp::new("test") - .binding( - fern_cli_sdk::openapi::OpenApiBinding::new() - .spec(include_str!("../cli/openapi-fixture/openapi.yaml")) - .auth_scheme_env("bearer", "TEST_TOKEN"), - ) - .command( - clap::Command::new("custom").about("A custom command"), - fern_cli_sdk::openapi::OpenApiBinding::handler(custom_handler), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let yaml = include_str!("../cli/openapi-fixture/openapi.yaml"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(yaml, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/generators/cli/sdk/tests/overlay_fixture.rs b/generators/cli/sdk/tests/overlay_fixture.rs deleted file mode 100644 index 2a733be2604f..000000000000 --- a/generators/cli/sdk/tests/overlay_fixture.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Integration coverage for overlay application against the SDK template's -//! own dev fixture (`cli/openapi-fixture/openapi.yaml`). -//! -//! These previously lived inline in `src/openapi/overlay.rs`. They are -//! pure integration tests — they `include_str!` the dev fixture and exec -//! the full overlay → discovery pipeline — so they don't belong in unit -//! tests. They are filtered out of generated CLI output by `SDK_IGNORE` -//! in `generators/cli/build.mjs` because the fixture path doesn't ship. - -use fern_cli_sdk::openapi::{apply_overlays_to_spec, load_openapi_spec}; - -#[test] -fn test_overlay_on_fixture_spec() { - let spec = include_str!("../cli/openapi-fixture/openapi.yaml"); - let overlay = r#" -overlay: "1.0.0" -info: - title: fixture-overlay - version: "1.0.0" -actions: - - target: "$.info" - update: - description: "Modified by overlay" - - target: "$.paths['/users'].get" - update: - x-fern-sdk-method-name: listAllUsers - - target: "$.paths['/users'].get.parameters" - update: - name: offset - in: query - schema: - type: integer - - target: "$.paths['/files/{file_id}/thumbnail']" - remove: true -"#; - let result = apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); - let doc: serde_json::Value = serde_yaml::from_str(&result).unwrap(); - - // Verify info.description was set - assert_eq!(doc["info"]["description"], "Modified by overlay"); - - // Verify method rename - assert_eq!( - doc["paths"]["/users"]["get"]["x-fern-sdk-method-name"], - "listAllUsers" - ); - - // Verify array append (new parameter added) - let params = doc["paths"]["/users"]["get"]["parameters"] - .as_array() - .unwrap(); - let has_offset = params.iter().any(|p| p["name"] == "offset"); - assert!(has_offset, "offset param should be appended: {params:?}"); - - // Verify remove - assert!( - doc["paths"]["/files/{file_id}/thumbnail"].is_null(), - "thumbnail path should be removed" - ); - - // Verify untouched paths still exist - assert!( - !doc["paths"]["/files/{file_id}"].is_null(), - "other file paths should remain" - ); -} - -#[test] -fn test_overlay_then_load_openapi_spec_finds_resources() { - let spec = include_str!("../cli/openapi-fixture/openapi.yaml"); - let overlay = r#" -overlay: "1.0.0" -info: - title: fixture-overlay - version: "1.0.0" -actions: - - target: "$.paths['/files/{file_id}/thumbnail']" - remove: true -"#; - - let merged = apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); - let doc = load_openapi_spec(&merged, "overlay-fixture").unwrap(); - - // files, folders, and users groups should still exist - assert!(doc.resources.contains_key("files"), "files group missing"); - assert!(doc.resources.contains_key("folders"), "folders group missing"); - assert!(doc.resources.contains_key("users"), "users group missing"); - - // getThumbnail should be gone from the files resource - let files = &doc.resources["files"]; - assert!( - !files.methods.contains_key("getThumbnail"), - "getThumbnail should be removed: {:?}", - files.methods.keys().collect::>() - ); - // Other file operations should still exist - assert!( - files.methods.contains_key("get"), - "get should remain: {:?}", - files.methods.keys().collect::>() - ); - assert!( - files.methods.contains_key("update"), - "update should remain: {:?}", - files.methods.keys().collect::>() - ); -} diff --git a/generators/cli/sdk/tests/tls_env_vars.rs b/generators/cli/sdk/tests/tls_env_vars.rs deleted file mode 100644 index a5de7ede19e5..000000000000 --- a/generators/cli/sdk/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `BIGCOMMERCE_CA_BUNDLE`, `BIGCOMMERCE_INSECURE`, -//! `SSL_CERT_FILE`, etc. actually change the TLS trust outcome of the -//! HTTP client built by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (the reason live tests against -//! BigCommerce can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} From b6a6467a47e086dfac618ccf1861270d10a4e916 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:28:00 -0400 Subject: [PATCH 04/14] fix(parser): resolve nested $ref objects in allOf flattening during v3 OpenAPI parsing (#16162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve nested $ref objects in allOf flattening during v3 OpenAPI parsing When a parent schema in an allOf composition itself uses allOf with $ref elements (e.g., userPost → userBase → userStrict), the v3 parser's flattenNestedAllOf was silently dropping those nested $ref objects. This caused parent properties and required arrays to be lost in docs rendering. The fix adds an optional resolveRef callback to mergeAllOfSchemas that flattenNestedAllOf uses to resolve nested $ref objects instead of filtering them out. Cycle detection via a visited set prevents infinite recursion. Co-Authored-By: will.kendall@buildwithfern.com * fix: skip unresolved ref aliases in nested allOf resolver, update stale url-reference snapshots Guard against ref chains (local alias → external URL ref) that resolve to another ReferenceObject instead of a schema. The resolver now returns undefined for these, preserving the existing filter-out behavior. Also updates the pre-existing stale url-reference test snapshots (the external spec changed upstream; identical output on main). Co-Authored-By: will.kendall@buildwithfern.com * test: add comprehensive testing for nested $ref resolution in allOf - Add permanent v3-importer-tests fixture (allof-nested-ref) with 3 test cases: three-level chain, sibling properties + nested ref, multiple refs in single allOf - Add 4 new unit test edge cases: required merging across ref boundaries, multiple refs in single allOf, sibling properties preservation, undefined resolver - Extend existing allof seed test fixture with Cases 7-8 (nested $ref chain, multiple nested refs in parent allOf) - Run seed tests through TS, Python, Java, Go generators - all pass - Total: 26 unit tests, 286 v3-importer-tests, 4 seed generators verified Co-Authored-By: will.kendall@buildwithfern.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: will.kendall@buildwithfern.com --- .../src/converters/schema/SchemaConverter.ts | 12 +- .../schema/__test__/mergeAllOfSchemas.test.ts | 224 +- .../converters/schema/mergeAllOfSchemas.ts | 40 +- .../baseline-sdks/allof-nested-ref.json | 3450 +++++++++++++++ .../baseline-sdks/url-reference.json | 3009 ++----------- .../v3-sdks/allof-nested-ref.json | 3194 ++++++++++++++ .../__snapshots__/v3-sdks/url-reference.json | 3707 +---------------- .../allof-nested-ref/fern/fern.config.json | 4 + .../allof-nested-ref/fern/generators.yml | 4 + .../fixtures/allof-nested-ref/openapi.yml | 221 + .../fix-allof-nested-ref-resolution.yml | 6 + seed/go-sdk/allof/.fern/metadata.json | 3 +- seed/go-sdk/allof/client/client.go | 34 + seed/go-sdk/allof/client/raw_client.go | 85 + .../dynamic-snippets/example10/snippet.go | 27 + .../dynamic-snippets/example11/snippet.go | 39 + .../dynamic-snippets/example12/snippet.go | 24 + .../dynamic-snippets/example13/snippet.go | 41 + seed/go-sdk/allof/reference.md | 141 + seed/go-sdk/allof/snippet.json | 22 + seed/go-sdk/allof/types.go | 921 ++++ seed/go-sdk/allof/types_test.go | 3066 ++++++++++++-- seed/java-sdk/allof/.fern/metadata.json | 3 +- seed/java-sdk/allof/reference.md | 137 + seed/java-sdk/allof/snippet.json | 26 + .../com/seed/api/AsyncRawSeedApiClient.java | 135 + .../java/com/seed/api/AsyncSeedApiClient.java | 31 + .../java/com/seed/api/RawSeedApiClient.java | 107 + .../main/java/com/seed/api/SeedApiClient.java | 31 + .../java/com/seed/api/requests/PlantPost.java | 422 ++ .../java/com/seed/api/types/IPlantBase.java | 12 + .../java/com/seed/api/types/IPlantStrict.java | 12 + .../java/com/seed/api/types/ITreeBase.java | 12 + .../com/seed/api/types/ITreeDescribable.java | 12 + .../com/seed/api/types/ITreeIdentifiable.java | 8 + .../java/com/seed/api/types/PlantBase.java | 280 ++ .../api/types/PlantBaseWateringFrequency.java | 105 + .../seed/api/types/PlantPostSunExposure.java | 93 + .../java/com/seed/api/types/PlantStrict.java | 198 + .../java/com/seed/api/types/TreeBase.java | 310 ++ .../com/seed/api/types/TreeDescribable.java | 142 + .../com/seed/api/types/TreeIdentifiable.java | 130 + .../java/com/seed/api/types/TreeRecord.java | 355 ++ .../src/main/java/com/snippets/Example10.java | 19 + .../src/main/java/com/snippets/Example11.java | 24 + .../src/main/java/com/snippets/Example12.java | 13 + .../src/main/java/com/snippets/Example13.java | 20 + .../no-custom-config/.fern/metadata.json | 3 +- .../allof/no-custom-config/reference.md | 203 + .../allof/no-custom-config/snippet.json | 26 + .../no-custom-config/src/seed/__init__.py | 24 + .../allof/no-custom-config/src/seed/client.py | 293 ++ .../no-custom-config/src/seed/raw_client.py | 329 ++ .../src/seed/types/__init__.py | 24 + .../src/seed/types/plant_base.py | 32 + .../types/plant_base_watering_frequency.py | 5 + .../src/seed/types/plant_post_sun_exposure.py | 5 + .../src/seed/types/plant_strict.py | 32 + .../src/seed/types/tree_base.py | 32 + .../src/seed/types/tree_describable.py | 30 + .../src/seed/types/tree_identifiable.py | 22 + .../src/seed/types/tree_record.py | 27 + .../no-custom-config/tests/wire/test_.py | 23 + .../wiremock/wiremock-mappings.json | 54 +- seed/ts-sdk/allof/.fern/metadata.json | 3 +- seed/ts-sdk/allof/reference.md | 133 + seed/ts-sdk/allof/snippet.json | 22 + seed/ts-sdk/allof/src/Client.ts | 117 + .../src/api/client/requests/PlantPost.ts | 31 + .../allof/src/api/client/requests/index.ts | 1 + seed/ts-sdk/allof/src/api/types/PlantBase.ts | 19 + .../ts-sdk/allof/src/api/types/PlantStrict.ts | 10 + seed/ts-sdk/allof/src/api/types/TreeBase.ts | 10 + .../allof/src/api/types/TreeDescribable.ts | 8 + .../allof/src/api/types/TreeIdentifiable.ts | 6 + seed/ts-sdk/allof/src/api/types/TreeRecord.ts | 8 + seed/ts-sdk/allof/src/api/types/index.ts | 6 + seed/ts-sdk/allof/tests/wire/main.test.ts | 52 + test-definitions/fern/apis/allof/openapi.yml | 167 +- 79 files changed, 15982 insertions(+), 6686 deletions(-) create mode 100644 packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/allof-nested-ref.json create mode 100644 packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/allof-nested-ref.json create mode 100644 packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/fern/fern.config.json create mode 100644 packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/fern/generators.yml create mode 100644 packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/openapi.yml create mode 100644 packages/cli/cli/changes/unreleased/fix-allof-nested-ref-resolution.yml create mode 100644 seed/go-sdk/allof/dynamic-snippets/example10/snippet.go create mode 100644 seed/go-sdk/allof/dynamic-snippets/example11/snippet.go create mode 100644 seed/go-sdk/allof/dynamic-snippets/example12/snippet.go create mode 100644 seed/go-sdk/allof/dynamic-snippets/example13/snippet.go create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/requests/PlantPost.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/IPlantBase.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/IPlantStrict.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeBase.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeDescribable.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeIdentifiable.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantBase.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantBaseWateringFrequency.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantPostSunExposure.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantStrict.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeBase.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeDescribable.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeIdentifiable.java create mode 100644 seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeRecord.java create mode 100644 seed/java-sdk/allof/src/main/java/com/snippets/Example10.java create mode 100644 seed/java-sdk/allof/src/main/java/com/snippets/Example11.java create mode 100644 seed/java-sdk/allof/src/main/java/com/snippets/Example12.java create mode 100644 seed/java-sdk/allof/src/main/java/com/snippets/Example13.java create mode 100644 seed/python-sdk/allof/no-custom-config/src/seed/types/plant_base.py create mode 100644 seed/python-sdk/allof/no-custom-config/src/seed/types/plant_base_watering_frequency.py create mode 100644 seed/python-sdk/allof/no-custom-config/src/seed/types/plant_post_sun_exposure.py create mode 100644 seed/python-sdk/allof/no-custom-config/src/seed/types/plant_strict.py create mode 100644 seed/python-sdk/allof/no-custom-config/src/seed/types/tree_base.py create mode 100644 seed/python-sdk/allof/no-custom-config/src/seed/types/tree_describable.py create mode 100644 seed/python-sdk/allof/no-custom-config/src/seed/types/tree_identifiable.py create mode 100644 seed/python-sdk/allof/no-custom-config/src/seed/types/tree_record.py create mode 100644 seed/ts-sdk/allof/src/api/client/requests/PlantPost.ts create mode 100644 seed/ts-sdk/allof/src/api/types/PlantBase.ts create mode 100644 seed/ts-sdk/allof/src/api/types/PlantStrict.ts create mode 100644 seed/ts-sdk/allof/src/api/types/TreeBase.ts create mode 100644 seed/ts-sdk/allof/src/api/types/TreeDescribable.ts create mode 100644 seed/ts-sdk/allof/src/api/types/TreeIdentifiable.ts create mode 100644 seed/ts-sdk/allof/src/api/types/TreeRecord.ts diff --git a/packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts b/packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts index 3dae42f80521..0c440553c2a2 100644 --- a/packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts +++ b/packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts @@ -319,7 +319,17 @@ export class SchemaConverter extends AbstractConverter { + const resolved = this.context.resolveMaybeReference({ + schemaOrReference: ref, + breadcrumbs: this.breadcrumbs + }); + // Skip if result is still a reference (e.g. URL ref alias) + if (resolved != null && this.context.isReferenceObject(resolved)) { + return undefined; + } + return resolved; + }); const allResolvedRefs = new Set([...this.visitedRefs, ...localResolvedRefs]); const mergedConverter = new SchemaConverter({ diff --git a/packages/cli/api-importers/v3-importer-commons/src/converters/schema/__test__/mergeAllOfSchemas.test.ts b/packages/cli/api-importers/v3-importer-commons/src/converters/schema/__test__/mergeAllOfSchemas.test.ts index 6aa9f5ba0f48..028598873a0a 100644 --- a/packages/cli/api-importers/v3-importer-commons/src/converters/schema/__test__/mergeAllOfSchemas.test.ts +++ b/packages/cli/api-importers/v3-importer-commons/src/converters/schema/__test__/mergeAllOfSchemas.test.ts @@ -143,7 +143,7 @@ describe("mergeAllOfSchemas", () => { expect(result.allOf).toBeUndefined(); }); - it("filters out $ref objects from nested allOf arrays", () => { + it("filters out $ref objects from nested allOf arrays when no resolver provided", () => { const result = mergeAllOfSchemas({} as Schema, [ { required: ["id"], @@ -162,6 +162,228 @@ describe("mergeAllOfSchemas", () => { expect("$ref" in result).toBe(false); }); + it("resolves $ref objects in nested allOf when resolver is provided", () => { + const resolver = (ref: { $ref: string }) => { + if (ref.$ref === "#/components/schemas/Base") { + return { + type: "object", + required: ["baseField"], + properties: { + baseField: { type: "string" }, + baseOptional: { type: "number" } + } + } as Schema; + } + return undefined; + }; + const result = mergeAllOfSchemas( + {} as Schema, + [ + { + required: ["id"], + properties: { id: { type: "string" } }, + allOf: [ + { $ref: "#/components/schemas/Base" } as unknown as Schema, + { required: ["name"], properties: { name: { type: "string" } } } + ] + } as Schema + ], + resolver + ); + expect(result.properties?.["id"]).toBeDefined(); + expect(result.properties?.["name"]).toBeDefined(); + expect(result.properties?.["baseField"]).toBeDefined(); + expect(result.properties?.["baseOptional"]).toBeDefined(); + expect(result.required).toEqual(expect.arrayContaining(["id", "name", "baseField"])); + expect("$ref" in result).toBe(false); + }); + + it("resolves deeply nested $ref chains with resolver", () => { + const resolver = (ref: { $ref: string }) => { + if (ref.$ref === "#/components/schemas/GrandParent") { + return { + properties: { ancestorProp: { type: "boolean" } }, + allOf: [{ $ref: "#/components/schemas/Root" } as unknown as Schema] + } as Schema; + } + if (ref.$ref === "#/components/schemas/Root") { + return { + properties: { rootProp: { type: "integer" } } + } as Schema; + } + return undefined; + }; + const result = mergeAllOfSchemas( + {} as Schema, + [ + { + properties: { childProp: { type: "string" } }, + allOf: [{ $ref: "#/components/schemas/GrandParent" } as unknown as Schema] + } as Schema + ], + resolver + ); + expect(result.properties?.["childProp"]).toBeDefined(); + expect(result.properties?.["ancestorProp"]).toBeDefined(); + expect(result.properties?.["rootProp"]).toBeDefined(); + }); + + it("handles circular $ref in nested allOf with resolver", () => { + const resolver = (ref: { $ref: string }) => { + if (ref.$ref === "#/components/schemas/A") { + return { + properties: { aProp: { type: "string" } }, + allOf: [{ $ref: "#/components/schemas/B" } as unknown as Schema] + } as Schema; + } + if (ref.$ref === "#/components/schemas/B") { + return { + properties: { bProp: { type: "number" } }, + allOf: [{ $ref: "#/components/schemas/A" } as unknown as Schema] + } as Schema; + } + return undefined; + }; + const result = mergeAllOfSchemas( + {} as Schema, + [ + { + properties: { local: { type: "boolean" } }, + allOf: [{ $ref: "#/components/schemas/A" } as unknown as Schema] + } as Schema + ], + resolver + ); + expect(result.properties?.["local"]).toBeDefined(); + expect(result.properties?.["aProp"]).toBeDefined(); + expect(result.properties?.["bProp"]).toBeDefined(); + expect("$ref" in result).toBe(false); + }); + + it("merges required arrays across resolved $ref boundaries", () => { + const resolver = (ref: { $ref: string }) => { + if (ref.$ref === "#/components/schemas/Parent") { + return { + type: "object", + required: ["parentField"], + properties: { + parentField: { type: "string" }, + parentOptional: { type: "number" } + } + } as Schema; + } + return undefined; + }; + const result = mergeAllOfSchemas( + {} as Schema, + [ + { + required: ["childField", "parentField"], + properties: { childField: { type: "boolean" } }, + allOf: [{ $ref: "#/components/schemas/Parent" } as unknown as Schema] + } as Schema + ], + resolver + ); + expect(result.required).toEqual(expect.arrayContaining(["parentField", "childField"])); + expect(result.required).toHaveLength(2); + expect(result.properties?.["parentField"]).toBeDefined(); + expect(result.properties?.["parentOptional"]).toBeDefined(); + expect(result.properties?.["childField"]).toBeDefined(); + }); + + it("resolves multiple $ref entries in a single nested allOf", () => { + const resolver = (ref: { $ref: string }) => { + if (ref.$ref === "#/components/schemas/Identifiable") { + return { + type: "object", + required: ["id"], + properties: { id: { type: "string" } } + } as Schema; + } + if (ref.$ref === "#/components/schemas/Describable") { + return { + type: "object", + properties: { + name: { type: "string" }, + description: { type: "string" } + } + } as Schema; + } + return undefined; + }; + const result = mergeAllOfSchemas( + {} as Schema, + [ + { + properties: { species: { type: "string" } }, + allOf: [ + { $ref: "#/components/schemas/Identifiable" } as unknown as Schema, + { $ref: "#/components/schemas/Describable" } as unknown as Schema + ] + } as Schema + ], + resolver + ); + expect(result.properties?.["id"]).toBeDefined(); + expect(result.properties?.["name"]).toBeDefined(); + expect(result.properties?.["description"]).toBeDefined(); + expect(result.properties?.["species"]).toBeDefined(); + expect(result.required).toEqual(["id"]); + }); + + it("preserves sibling properties when parent has both $ref and own properties", () => { + const resolver = (ref: { $ref: string }) => { + if (ref.$ref === "#/components/schemas/Address") { + return { + type: "object", + required: ["country"], + properties: { + country: { type: "string" }, + city: { type: "string" } + } + } as Schema; + } + return undefined; + }; + const result = mergeAllOfSchemas( + {} as Schema, + [ + { + type: "object", + properties: { + companyName: { type: "string" }, + companyPhone: { type: "string" } + }, + allOf: [{ $ref: "#/components/schemas/Address" } as unknown as Schema] + } as Schema + ], + resolver + ); + expect(result.properties?.["country"]).toBeDefined(); + expect(result.properties?.["city"]).toBeDefined(); + expect(result.properties?.["companyName"]).toBeDefined(); + expect(result.properties?.["companyPhone"]).toBeDefined(); + expect(result.required).toEqual(["country"]); + }); + + it("returns undefined resolver results gracefully", () => { + const resolver = () => undefined; + const result = mergeAllOfSchemas( + {} as Schema, + [ + { + required: ["id"], + properties: { id: { type: "string" } }, + allOf: [{ $ref: "#/components/schemas/Unknown" } as unknown as Schema] + } as Schema + ], + resolver + ); + expect(result.properties?.["id"]).toBeDefined(); + expect(result.required).toEqual(["id"]); + }); + it("recursively flattens doubly-nested allOf", () => { const result = mergeAllOfSchemas({} as Schema, [ { diff --git a/packages/cli/api-importers/v3-importer-commons/src/converters/schema/mergeAllOfSchemas.ts b/packages/cli/api-importers/v3-importer-commons/src/converters/schema/mergeAllOfSchemas.ts index 50ca28fd40af..a38037d2071d 100644 --- a/packages/cli/api-importers/v3-importer-commons/src/converters/schema/mergeAllOfSchemas.ts +++ b/packages/cli/api-importers/v3-importer-commons/src/converters/schema/mergeAllOfSchemas.ts @@ -26,11 +26,14 @@ const MIN_OF_MAXS_KEYS = ["maximum", "exclusiveMaximum", "maxLength", "maxItems" const SKIP_FROM_CHILDREN = ["allOf", "oneOf", "anyOf"]; +export type RefResolver = (ref: OpenAPIV3_1.ReferenceObject) => OpenAPIV3_1.SchemaObject | undefined; + export function mergeAllOfSchemas( outerSchema: OpenAPIV3_1.SchemaObject, - elements: OpenAPIV3_1.SchemaObject[] + elements: OpenAPIV3_1.SchemaObject[], + resolveRef?: RefResolver ): OpenAPIV3_1.SchemaObject { - const flatElements = flattenNestedAllOf(elements); + const flatElements = flattenNestedAllOf(elements, resolveRef); let result: Record = {}; for (const element of flatElements) { @@ -42,17 +45,36 @@ export function mergeAllOfSchemas( return result as OpenAPIV3_1.SchemaObject; } -function flattenNestedAllOf(elements: OpenAPIV3_1.SchemaObject[]): OpenAPIV3_1.SchemaObject[] { +function flattenNestedAllOf( + elements: OpenAPIV3_1.SchemaObject[], + resolveRef?: RefResolver, + visitedRefs?: Set +): OpenAPIV3_1.SchemaObject[] { + const visited = visitedRefs ?? new Set(); const flat: OpenAPIV3_1.SchemaObject[] = []; for (const element of elements) { if (Array.isArray(element.allOf) && element.allOf.length > 0) { const { allOf, ...sibling } = element; - // Filter out ReferenceObjects — only include resolved SchemaObjects. - // Unresolved $ref entries would corrupt the merged result with a - // spurious top-level $ref key. - const schemaChildren = allOf.filter((child): child is OpenAPIV3_1.SchemaObject => !("$ref" in child)); - // Recursively flatten in case extracted children also contain allOf - flat.push(...flattenNestedAllOf(schemaChildren)); + const schemaChildren: OpenAPIV3_1.SchemaObject[] = []; + for (const child of allOf) { + if ("$ref" in child) { + if (resolveRef == null) { + continue; + } + const refPath = (child as OpenAPIV3_1.ReferenceObject).$ref; + if (visited.has(refPath)) { + continue; + } + visited.add(refPath); + const resolved = resolveRef(child as OpenAPIV3_1.ReferenceObject); + if (resolved != null) { + schemaChildren.push(resolved); + } + } else { + schemaChildren.push(child as OpenAPIV3_1.SchemaObject); + } + } + flat.push(...flattenNestedAllOf(schemaChildren, resolveRef, visited)); if (Object.keys(sibling).length > 0) { flat.push(sibling as OpenAPIV3_1.SchemaObject); } diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/allof-nested-ref.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/allof-nested-ref.json new file mode 100644 index 000000000000..d86fd230c8f9 --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/allof-nested-ref.json @@ -0,0 +1,3450 @@ +{ + "selfHosted": false, + "apiName": "api", + "apiDisplayName": "Nested allOf $ref Resolution", + "auth": { + "requirement": "ALL", + "schemes": [] + }, + "headers": [], + "idempotencyHeaders": [], + "types": { + "type_:UserStrict": { + "name": { + "name": "UserStrict", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:UserStrict" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "firstName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "lastName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "email", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "email" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:UserBase": { + "name": { + "name": "UserBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:UserBase" + }, + "shape": { + "extends": [ + { + "name": "UserStrict", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:UserStrict" + } + ], + "properties": [ + { + "docs": "The user's role", + "name": "role", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "docs": "Associated company ID", + "name": "companyId", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [ + { + "name": "firstName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "lastName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "email", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "email" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:AddressBase": { + "name": { + "name": "AddressBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:AddressBase" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "country", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "state", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "city", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:CompanyBase": { + "name": { + "name": "CompanyBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:CompanyBase" + }, + "shape": { + "extends": [ + { + "name": "AddressBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:AddressBase" + } + ], + "properties": [ + { + "name": "companyName", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "companyPhone", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [ + { + "name": "country", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "state", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "city", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:Identifiable": { + "name": { + "name": "Identifiable", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Identifiable" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "uuid" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:Describable": { + "name": { + "name": "Describable", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Describable" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "description", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:PlantBaseWateringFrequency": { + "inline": true, + "name": { + "name": "PlantBaseWateringFrequency", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PlantBaseWateringFrequency" + }, + "shape": { + "values": [ + { + "name": "daily" + }, + { + "name": "weekly" + }, + { + "name": "biweekly" + }, + { + "name": "monthly" + } + ], + "type": "enum" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:PlantBase": { + "name": { + "name": "PlantBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PlantBase" + }, + "shape": { + "extends": [ + { + "name": "Identifiable", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Identifiable" + }, + { + "name": "Describable", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Describable" + } + ], + "properties": [ + { + "name": "species", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "wateringFrequency", + "valueType": { + "container": { + "optional": { + "name": "PlantBaseWateringFrequency", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PlantBaseWateringFrequency", + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [ + { + "name": "id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "uuid" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "description", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:PlantRecord": { + "name": { + "name": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PlantRecord" + }, + "shape": { + "extends": [ + { + "name": "PlantBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PlantBase" + } + ], + "properties": [ + { + "docs": "Date the plant was planted", + "name": "plantedAt", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "DATE" + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [ + { + "name": "species", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "wateringFrequency", + "valueType": { + "container": { + "optional": { + "name": "PlantBaseWateringFrequency", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PlantBaseWateringFrequency", + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "uuid" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "description", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + } + }, + "errors": {}, + "services": { + "service_": { + "name": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "basePath": { + "head": "", + "parts": [] + }, + "headers": [], + "pathParameters": [], + "encoding": { + "json": {} + }, + "transport": { + "type": "http" + }, + "endpoints": [ + { + "docs": "Tests the core BigCommerce pattern: userPost -> userBase -> userStrict. The grandparent's properties must be resolved through the nested $ref.", + "id": "endpoint_.createUser", + "name": "createUser", + "displayName": "Create a user (three-level allOf chain)", + "auth": false, + "idempotent": false, + "method": "POST", + "path": { + "head": "/users", + "parts": [] + }, + "fullPath": { + "head": "users", + "parts": [] + }, + "pathParameters": [], + "allPathParameters": [], + "queryParameters": [], + "headers": [], + "requestBody": { + "name": "UserPost", + "extends": [ + { + "name": "UserBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:UserBase" + } + ], + "contentType": "application/json", + "properties": [ + { + "docs": "Whether to send a welcome email", + "name": "acceptWelcomeEmail", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "docs": "Origin channel ID", + "name": "originChannelId", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "docs": "List of channel IDs", + "name": "channelIds", + "valueType": { + "container": { + "list": { + "primitive": { + "v1": "INTEGER", + "v2": { + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "list" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [ + { + "docs": "The user's role", + "name": "role", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "docs": "Associated company ID", + "name": "companyId", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "firstName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "lastName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "email", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "email" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "type": "inlinedRequestBody" + }, + "sdkRequest": { + "shape": { + "wrapperName": "UserPost", + "bodyKey": "body", + "includePathParameters": false, + "onlyPathParameters": false, + "type": "wrapper" + }, + "requestParameterName": "request" + }, + "response": { + "body": { + "value": { + "docs": "Created user", + "responseBodyType": { + "name": "UserStrict", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:UserStrict", + "type": "named" + }, + "type": "response" + }, + "type": "json" + }, + "statusCode": 200, + "docs": "Created user" + }, + "errors": [], + "userSpecifiedExamples": [], + "autogeneratedExamples": [], + "responseHeaders": [] + }, + { + "docs": "Tests that sibling properties at the parent level (companyBase has its own properties in addition to $ref) are all preserved.", + "id": "endpoint_.createCompany", + "name": "createCompany", + "displayName": "Create a company (sibling properties alongside nested $ref)", + "auth": false, + "idempotent": false, + "method": "POST", + "path": { + "head": "/companies", + "parts": [] + }, + "fullPath": { + "head": "companies", + "parts": [] + }, + "pathParameters": [], + "allPathParameters": [], + "queryParameters": [], + "headers": [], + "requestBody": { + "name": "CompanyPost", + "extends": [ + { + "name": "CompanyBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:CompanyBase" + } + ], + "contentType": "application/json", + "properties": [ + { + "docs": "Tax identification number", + "name": "taxId", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [ + { + "name": "companyName", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "companyPhone", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "country", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "state", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "city", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "type": "inlinedRequestBody" + }, + "sdkRequest": { + "shape": { + "wrapperName": "CompanyPost", + "bodyKey": "body", + "includePathParameters": false, + "onlyPathParameters": false, + "type": "wrapper" + }, + "requestParameterName": "request" + }, + "response": { + "body": { + "value": { + "docs": "Created company", + "responseBodyType": { + "name": "CompanyBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:CompanyBase", + "type": "named" + }, + "type": "response" + }, + "type": "json" + }, + "statusCode": 200, + "docs": "Created company" + }, + "errors": [], + "userSpecifiedExamples": [], + "autogeneratedExamples": [], + "responseHeaders": [] + }, + { + "docs": "Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged.", + "id": "endpoint_.createPlant", + "name": "createPlant", + "displayName": "Create a plant (multiple $ref in single allOf)", + "auth": false, + "idempotent": false, + "method": "POST", + "path": { + "head": "/plants", + "parts": [] + }, + "fullPath": { + "head": "plants", + "parts": [] + }, + "pathParameters": [], + "allPathParameters": [], + "queryParameters": [], + "headers": [], + "requestBody": { + "requestBodyType": { + "name": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PlantRecord", + "type": "named" + }, + "type": "reference" + }, + "sdkRequest": { + "shape": { + "value": { + "requestBodyType": { + "name": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PlantRecord", + "type": "named" + }, + "type": "typeReference" + }, + "type": "justRequestBody" + }, + "requestParameterName": "request" + }, + "response": { + "body": { + "value": { + "docs": "Created plant", + "responseBodyType": { + "name": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PlantRecord", + "type": "named" + }, + "type": "response" + }, + "type": "json" + }, + "statusCode": 200, + "docs": "Created plant" + }, + "errors": [], + "userSpecifiedExamples": [], + "autogeneratedExamples": [], + "responseHeaders": [] + } + ] + } + }, + "constants": { + "errorInstanceIdKey": "errorInstanceId" + }, + "environments": { + "defaultEnvironment": "Default", + "environments": { + "environments": [ + { + "id": "Default", + "name": "Default", + "url": "https://api.example.com" + } + ], + "type": "singleBaseUrl" + } + }, + "errorDiscriminationStrategy": { + "type": "statusCode" + }, + "pathParameters": [], + "variables": [], + "serviceTypeReferenceInfo": { + "typesReferencedOnlyByService": { + "service_": [ + "type_:UserStrict", + "type_:UserBase", + "type_:AddressBase", + "type_:CompanyBase", + "type_:Identifiable", + "type_:Describable", + "type_:PlantBaseWateringFrequency", + "type_:PlantBase", + "type_:PlantRecord" + ] + }, + "sharedTypes": [] + }, + "webhookGroups": {}, + "websocketChannels": {}, + "dynamic": { + "version": "1.0.0", + "types": { + "type_:UserStrict": { + "declaration": { + "name": { + "originalName": "UserStrict", + "camelCase": { + "unsafeName": "userStrict", + "safeName": "userStrict" + }, + "snakeCase": { + "unsafeName": "user_strict", + "safeName": "user_strict" + }, + "screamingSnakeCase": { + "unsafeName": "USER_STRICT", + "safeName": "USER_STRICT" + }, + "pascalCase": { + "unsafeName": "UserStrict", + "safeName": "UserStrict" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "firstName", + "name": { + "originalName": "firstName", + "camelCase": { + "unsafeName": "firstName", + "safeName": "firstName" + }, + "snakeCase": { + "unsafeName": "first_name", + "safeName": "first_name" + }, + "screamingSnakeCase": { + "unsafeName": "FIRST_NAME", + "safeName": "FIRST_NAME" + }, + "pascalCase": { + "unsafeName": "FirstName", + "safeName": "FirstName" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "lastName", + "name": { + "originalName": "lastName", + "camelCase": { + "unsafeName": "lastName", + "safeName": "lastName" + }, + "snakeCase": { + "unsafeName": "last_name", + "safeName": "last_name" + }, + "screamingSnakeCase": { + "unsafeName": "LAST_NAME", + "safeName": "LAST_NAME" + }, + "pascalCase": { + "unsafeName": "LastName", + "safeName": "LastName" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "email", + "name": { + "originalName": "email", + "camelCase": { + "unsafeName": "email", + "safeName": "email" + }, + "snakeCase": { + "unsafeName": "email", + "safeName": "email" + }, + "screamingSnakeCase": { + "unsafeName": "EMAIL", + "safeName": "EMAIL" + }, + "pascalCase": { + "unsafeName": "Email", + "safeName": "Email" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_:UserBase": { + "declaration": { + "name": { + "originalName": "UserBase", + "camelCase": { + "unsafeName": "userBase", + "safeName": "userBase" + }, + "snakeCase": { + "unsafeName": "user_base", + "safeName": "user_base" + }, + "screamingSnakeCase": { + "unsafeName": "USER_BASE", + "safeName": "USER_BASE" + }, + "pascalCase": { + "unsafeName": "UserBase", + "safeName": "UserBase" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "firstName", + "name": { + "originalName": "firstName", + "camelCase": { + "unsafeName": "firstName", + "safeName": "firstName" + }, + "snakeCase": { + "unsafeName": "first_name", + "safeName": "first_name" + }, + "screamingSnakeCase": { + "unsafeName": "FIRST_NAME", + "safeName": "FIRST_NAME" + }, + "pascalCase": { + "unsafeName": "FirstName", + "safeName": "FirstName" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "lastName", + "name": { + "originalName": "lastName", + "camelCase": { + "unsafeName": "lastName", + "safeName": "lastName" + }, + "snakeCase": { + "unsafeName": "last_name", + "safeName": "last_name" + }, + "screamingSnakeCase": { + "unsafeName": "LAST_NAME", + "safeName": "LAST_NAME" + }, + "pascalCase": { + "unsafeName": "LastName", + "safeName": "LastName" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "email", + "name": { + "originalName": "email", + "camelCase": { + "unsafeName": "email", + "safeName": "email" + }, + "snakeCase": { + "unsafeName": "email", + "safeName": "email" + }, + "screamingSnakeCase": { + "unsafeName": "EMAIL", + "safeName": "EMAIL" + }, + "pascalCase": { + "unsafeName": "Email", + "safeName": "Email" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "role", + "name": { + "originalName": "role", + "camelCase": { + "unsafeName": "role", + "safeName": "role" + }, + "snakeCase": { + "unsafeName": "role", + "safeName": "role" + }, + "screamingSnakeCase": { + "unsafeName": "ROLE", + "safeName": "ROLE" + }, + "pascalCase": { + "unsafeName": "Role", + "safeName": "Role" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "companyId", + "name": { + "originalName": "companyId", + "camelCase": { + "unsafeName": "companyID", + "safeName": "companyID" + }, + "snakeCase": { + "unsafeName": "company_id", + "safeName": "company_id" + }, + "screamingSnakeCase": { + "unsafeName": "COMPANY_ID", + "safeName": "COMPANY_ID" + }, + "pascalCase": { + "unsafeName": "CompanyID", + "safeName": "CompanyID" + } + } + }, + "typeReference": { + "value": { + "value": "INTEGER", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "extends": [ + "type_:UserStrict" + ], + "additionalProperties": false, + "type": "object" + }, + "type_:AddressBase": { + "declaration": { + "name": { + "originalName": "AddressBase", + "camelCase": { + "unsafeName": "addressBase", + "safeName": "addressBase" + }, + "snakeCase": { + "unsafeName": "address_base", + "safeName": "address_base" + }, + "screamingSnakeCase": { + "unsafeName": "ADDRESS_BASE", + "safeName": "ADDRESS_BASE" + }, + "pascalCase": { + "unsafeName": "AddressBase", + "safeName": "AddressBase" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "country", + "name": { + "originalName": "country", + "camelCase": { + "unsafeName": "country", + "safeName": "country" + }, + "snakeCase": { + "unsafeName": "country", + "safeName": "country" + }, + "screamingSnakeCase": { + "unsafeName": "COUNTRY", + "safeName": "COUNTRY" + }, + "pascalCase": { + "unsafeName": "Country", + "safeName": "Country" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "state", + "name": { + "originalName": "state", + "camelCase": { + "unsafeName": "state", + "safeName": "state" + }, + "snakeCase": { + "unsafeName": "state", + "safeName": "state" + }, + "screamingSnakeCase": { + "unsafeName": "STATE", + "safeName": "STATE" + }, + "pascalCase": { + "unsafeName": "State", + "safeName": "State" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "city", + "name": { + "originalName": "city", + "camelCase": { + "unsafeName": "city", + "safeName": "city" + }, + "snakeCase": { + "unsafeName": "city", + "safeName": "city" + }, + "screamingSnakeCase": { + "unsafeName": "CITY", + "safeName": "CITY" + }, + "pascalCase": { + "unsafeName": "City", + "safeName": "City" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_:CompanyBase": { + "declaration": { + "name": { + "originalName": "CompanyBase", + "camelCase": { + "unsafeName": "companyBase", + "safeName": "companyBase" + }, + "snakeCase": { + "unsafeName": "company_base", + "safeName": "company_base" + }, + "screamingSnakeCase": { + "unsafeName": "COMPANY_BASE", + "safeName": "COMPANY_BASE" + }, + "pascalCase": { + "unsafeName": "CompanyBase", + "safeName": "CompanyBase" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "country", + "name": { + "originalName": "country", + "camelCase": { + "unsafeName": "country", + "safeName": "country" + }, + "snakeCase": { + "unsafeName": "country", + "safeName": "country" + }, + "screamingSnakeCase": { + "unsafeName": "COUNTRY", + "safeName": "COUNTRY" + }, + "pascalCase": { + "unsafeName": "Country", + "safeName": "Country" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "state", + "name": { + "originalName": "state", + "camelCase": { + "unsafeName": "state", + "safeName": "state" + }, + "snakeCase": { + "unsafeName": "state", + "safeName": "state" + }, + "screamingSnakeCase": { + "unsafeName": "STATE", + "safeName": "STATE" + }, + "pascalCase": { + "unsafeName": "State", + "safeName": "State" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "city", + "name": { + "originalName": "city", + "camelCase": { + "unsafeName": "city", + "safeName": "city" + }, + "snakeCase": { + "unsafeName": "city", + "safeName": "city" + }, + "screamingSnakeCase": { + "unsafeName": "CITY", + "safeName": "CITY" + }, + "pascalCase": { + "unsafeName": "City", + "safeName": "City" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "companyName", + "name": { + "originalName": "companyName", + "camelCase": { + "unsafeName": "companyName", + "safeName": "companyName" + }, + "snakeCase": { + "unsafeName": "company_name", + "safeName": "company_name" + }, + "screamingSnakeCase": { + "unsafeName": "COMPANY_NAME", + "safeName": "COMPANY_NAME" + }, + "pascalCase": { + "unsafeName": "CompanyName", + "safeName": "CompanyName" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "companyPhone", + "name": { + "originalName": "companyPhone", + "camelCase": { + "unsafeName": "companyPhone", + "safeName": "companyPhone" + }, + "snakeCase": { + "unsafeName": "company_phone", + "safeName": "company_phone" + }, + "screamingSnakeCase": { + "unsafeName": "COMPANY_PHONE", + "safeName": "COMPANY_PHONE" + }, + "pascalCase": { + "unsafeName": "CompanyPhone", + "safeName": "CompanyPhone" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "extends": [ + "type_:AddressBase" + ], + "additionalProperties": false, + "type": "object" + }, + "type_:Identifiable": { + "declaration": { + "name": { + "originalName": "Identifiable", + "camelCase": { + "unsafeName": "identifiable", + "safeName": "identifiable" + }, + "snakeCase": { + "unsafeName": "identifiable", + "safeName": "identifiable" + }, + "screamingSnakeCase": { + "unsafeName": "IDENTIFIABLE", + "safeName": "IDENTIFIABLE" + }, + "pascalCase": { + "unsafeName": "Identifiable", + "safeName": "Identifiable" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "id", + "name": { + "originalName": "id", + "camelCase": { + "unsafeName": "id", + "safeName": "id" + }, + "snakeCase": { + "unsafeName": "id", + "safeName": "id" + }, + "screamingSnakeCase": { + "unsafeName": "ID", + "safeName": "ID" + }, + "pascalCase": { + "unsafeName": "ID", + "safeName": "ID" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_:Describable": { + "declaration": { + "name": { + "originalName": "Describable", + "camelCase": { + "unsafeName": "describable", + "safeName": "describable" + }, + "snakeCase": { + "unsafeName": "describable", + "safeName": "describable" + }, + "screamingSnakeCase": { + "unsafeName": "DESCRIBABLE", + "safeName": "DESCRIBABLE" + }, + "pascalCase": { + "unsafeName": "Describable", + "safeName": "Describable" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "name", + "name": { + "originalName": "name", + "camelCase": { + "unsafeName": "name", + "safeName": "name" + }, + "snakeCase": { + "unsafeName": "name", + "safeName": "name" + }, + "screamingSnakeCase": { + "unsafeName": "NAME", + "safeName": "NAME" + }, + "pascalCase": { + "unsafeName": "Name", + "safeName": "Name" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "description", + "name": { + "originalName": "description", + "camelCase": { + "unsafeName": "description", + "safeName": "description" + }, + "snakeCase": { + "unsafeName": "description", + "safeName": "description" + }, + "screamingSnakeCase": { + "unsafeName": "DESCRIPTION", + "safeName": "DESCRIPTION" + }, + "pascalCase": { + "unsafeName": "Description", + "safeName": "Description" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_:PlantBaseWateringFrequency": { + "declaration": { + "name": { + "originalName": "PlantBaseWateringFrequency", + "camelCase": { + "unsafeName": "plantBaseWateringFrequency", + "safeName": "plantBaseWateringFrequency" + }, + "snakeCase": { + "unsafeName": "plant_base_watering_frequency", + "safeName": "plant_base_watering_frequency" + }, + "screamingSnakeCase": { + "unsafeName": "PLANT_BASE_WATERING_FREQUENCY", + "safeName": "PLANT_BASE_WATERING_FREQUENCY" + }, + "pascalCase": { + "unsafeName": "PlantBaseWateringFrequency", + "safeName": "PlantBaseWateringFrequency" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "values": [ + { + "wireValue": "daily", + "name": { + "originalName": "daily", + "camelCase": { + "unsafeName": "daily", + "safeName": "daily" + }, + "snakeCase": { + "unsafeName": "daily", + "safeName": "daily" + }, + "screamingSnakeCase": { + "unsafeName": "DAILY", + "safeName": "DAILY" + }, + "pascalCase": { + "unsafeName": "Daily", + "safeName": "Daily" + } + } + }, + { + "wireValue": "weekly", + "name": { + "originalName": "weekly", + "camelCase": { + "unsafeName": "weekly", + "safeName": "weekly" + }, + "snakeCase": { + "unsafeName": "weekly", + "safeName": "weekly" + }, + "screamingSnakeCase": { + "unsafeName": "WEEKLY", + "safeName": "WEEKLY" + }, + "pascalCase": { + "unsafeName": "Weekly", + "safeName": "Weekly" + } + } + }, + { + "wireValue": "biweekly", + "name": { + "originalName": "biweekly", + "camelCase": { + "unsafeName": "biweekly", + "safeName": "biweekly" + }, + "snakeCase": { + "unsafeName": "biweekly", + "safeName": "biweekly" + }, + "screamingSnakeCase": { + "unsafeName": "BIWEEKLY", + "safeName": "BIWEEKLY" + }, + "pascalCase": { + "unsafeName": "Biweekly", + "safeName": "Biweekly" + } + } + }, + { + "wireValue": "monthly", + "name": { + "originalName": "monthly", + "camelCase": { + "unsafeName": "monthly", + "safeName": "monthly" + }, + "snakeCase": { + "unsafeName": "monthly", + "safeName": "monthly" + }, + "screamingSnakeCase": { + "unsafeName": "MONTHLY", + "safeName": "MONTHLY" + }, + "pascalCase": { + "unsafeName": "Monthly", + "safeName": "Monthly" + } + } + } + ], + "type": "enum" + }, + "type_:PlantBase": { + "declaration": { + "name": { + "originalName": "PlantBase", + "camelCase": { + "unsafeName": "plantBase", + "safeName": "plantBase" + }, + "snakeCase": { + "unsafeName": "plant_base", + "safeName": "plant_base" + }, + "screamingSnakeCase": { + "unsafeName": "PLANT_BASE", + "safeName": "PLANT_BASE" + }, + "pascalCase": { + "unsafeName": "PlantBase", + "safeName": "PlantBase" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "species", + "name": { + "originalName": "species", + "camelCase": { + "unsafeName": "species", + "safeName": "species" + }, + "snakeCase": { + "unsafeName": "species", + "safeName": "species" + }, + "screamingSnakeCase": { + "unsafeName": "SPECIES", + "safeName": "SPECIES" + }, + "pascalCase": { + "unsafeName": "Species", + "safeName": "Species" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "wateringFrequency", + "name": { + "originalName": "wateringFrequency", + "camelCase": { + "unsafeName": "wateringFrequency", + "safeName": "wateringFrequency" + }, + "snakeCase": { + "unsafeName": "watering_frequency", + "safeName": "watering_frequency" + }, + "screamingSnakeCase": { + "unsafeName": "WATERING_FREQUENCY", + "safeName": "WATERING_FREQUENCY" + }, + "pascalCase": { + "unsafeName": "WateringFrequency", + "safeName": "WateringFrequency" + } + } + }, + "typeReference": { + "value": { + "value": "type_:PlantBaseWateringFrequency", + "type": "named" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "id", + "name": { + "originalName": "id", + "camelCase": { + "unsafeName": "id", + "safeName": "id" + }, + "snakeCase": { + "unsafeName": "id", + "safeName": "id" + }, + "screamingSnakeCase": { + "unsafeName": "ID", + "safeName": "ID" + }, + "pascalCase": { + "unsafeName": "ID", + "safeName": "ID" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "name", + "name": { + "originalName": "name", + "camelCase": { + "unsafeName": "name", + "safeName": "name" + }, + "snakeCase": { + "unsafeName": "name", + "safeName": "name" + }, + "screamingSnakeCase": { + "unsafeName": "NAME", + "safeName": "NAME" + }, + "pascalCase": { + "unsafeName": "Name", + "safeName": "Name" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "description", + "name": { + "originalName": "description", + "camelCase": { + "unsafeName": "description", + "safeName": "description" + }, + "snakeCase": { + "unsafeName": "description", + "safeName": "description" + }, + "screamingSnakeCase": { + "unsafeName": "DESCRIPTION", + "safeName": "DESCRIPTION" + }, + "pascalCase": { + "unsafeName": "Description", + "safeName": "Description" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "extends": [ + "type_:Identifiable", + "type_:Describable" + ], + "additionalProperties": false, + "type": "object" + }, + "type_:PlantRecord": { + "declaration": { + "name": { + "originalName": "PlantRecord", + "camelCase": { + "unsafeName": "plantRecord", + "safeName": "plantRecord" + }, + "snakeCase": { + "unsafeName": "plant_record", + "safeName": "plant_record" + }, + "screamingSnakeCase": { + "unsafeName": "PLANT_RECORD", + "safeName": "PLANT_RECORD" + }, + "pascalCase": { + "unsafeName": "PlantRecord", + "safeName": "PlantRecord" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "species", + "name": { + "originalName": "species", + "camelCase": { + "unsafeName": "species", + "safeName": "species" + }, + "snakeCase": { + "unsafeName": "species", + "safeName": "species" + }, + "screamingSnakeCase": { + "unsafeName": "SPECIES", + "safeName": "SPECIES" + }, + "pascalCase": { + "unsafeName": "Species", + "safeName": "Species" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "wateringFrequency", + "name": { + "originalName": "wateringFrequency", + "camelCase": { + "unsafeName": "wateringFrequency", + "safeName": "wateringFrequency" + }, + "snakeCase": { + "unsafeName": "watering_frequency", + "safeName": "watering_frequency" + }, + "screamingSnakeCase": { + "unsafeName": "WATERING_FREQUENCY", + "safeName": "WATERING_FREQUENCY" + }, + "pascalCase": { + "unsafeName": "WateringFrequency", + "safeName": "WateringFrequency" + } + } + }, + "typeReference": { + "value": { + "value": "type_:PlantBaseWateringFrequency", + "type": "named" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "id", + "name": { + "originalName": "id", + "camelCase": { + "unsafeName": "id", + "safeName": "id" + }, + "snakeCase": { + "unsafeName": "id", + "safeName": "id" + }, + "screamingSnakeCase": { + "unsafeName": "ID", + "safeName": "ID" + }, + "pascalCase": { + "unsafeName": "ID", + "safeName": "ID" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "name", + "name": { + "originalName": "name", + "camelCase": { + "unsafeName": "name", + "safeName": "name" + }, + "snakeCase": { + "unsafeName": "name", + "safeName": "name" + }, + "screamingSnakeCase": { + "unsafeName": "NAME", + "safeName": "NAME" + }, + "pascalCase": { + "unsafeName": "Name", + "safeName": "Name" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "description", + "name": { + "originalName": "description", + "camelCase": { + "unsafeName": "description", + "safeName": "description" + }, + "snakeCase": { + "unsafeName": "description", + "safeName": "description" + }, + "screamingSnakeCase": { + "unsafeName": "DESCRIPTION", + "safeName": "DESCRIPTION" + }, + "pascalCase": { + "unsafeName": "Description", + "safeName": "Description" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "plantedAt", + "name": { + "originalName": "plantedAt", + "camelCase": { + "unsafeName": "plantedAt", + "safeName": "plantedAt" + }, + "snakeCase": { + "unsafeName": "planted_at", + "safeName": "planted_at" + }, + "screamingSnakeCase": { + "unsafeName": "PLANTED_AT", + "safeName": "PLANTED_AT" + }, + "pascalCase": { + "unsafeName": "PlantedAt", + "safeName": "PlantedAt" + } + } + }, + "typeReference": { + "value": { + "value": "DATE", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "extends": [ + "type_:PlantBase" + ], + "additionalProperties": false, + "type": "object" + } + }, + "headers": [], + "endpoints": { + "endpoint_.createUser": { + "declaration": { + "name": { + "originalName": "createUser", + "camelCase": { + "unsafeName": "createUser", + "safeName": "createUser" + }, + "snakeCase": { + "unsafeName": "create_user", + "safeName": "create_user" + }, + "screamingSnakeCase": { + "unsafeName": "CREATE_USER", + "safeName": "CREATE_USER" + }, + "pascalCase": { + "unsafeName": "CreateUser", + "safeName": "CreateUser" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "location": { + "method": "POST", + "path": "/users" + }, + "request": { + "declaration": { + "name": { + "originalName": "UserPost", + "camelCase": { + "unsafeName": "userPost", + "safeName": "userPost" + }, + "snakeCase": { + "unsafeName": "user_post", + "safeName": "user_post" + }, + "screamingSnakeCase": { + "unsafeName": "USER_POST", + "safeName": "USER_POST" + }, + "pascalCase": { + "unsafeName": "UserPost", + "safeName": "UserPost" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "pathParameters": [], + "queryParameters": [], + "headers": [], + "body": { + "value": [ + { + "name": { + "wireValue": "role", + "name": { + "originalName": "role", + "camelCase": { + "unsafeName": "role", + "safeName": "role" + }, + "snakeCase": { + "unsafeName": "role", + "safeName": "role" + }, + "screamingSnakeCase": { + "unsafeName": "ROLE", + "safeName": "ROLE" + }, + "pascalCase": { + "unsafeName": "Role", + "safeName": "Role" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "companyId", + "name": { + "originalName": "companyId", + "camelCase": { + "unsafeName": "companyID", + "safeName": "companyID" + }, + "snakeCase": { + "unsafeName": "company_id", + "safeName": "company_id" + }, + "screamingSnakeCase": { + "unsafeName": "COMPANY_ID", + "safeName": "COMPANY_ID" + }, + "pascalCase": { + "unsafeName": "CompanyID", + "safeName": "CompanyID" + } + } + }, + "typeReference": { + "value": { + "value": "INTEGER", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "firstName", + "name": { + "originalName": "firstName", + "camelCase": { + "unsafeName": "firstName", + "safeName": "firstName" + }, + "snakeCase": { + "unsafeName": "first_name", + "safeName": "first_name" + }, + "screamingSnakeCase": { + "unsafeName": "FIRST_NAME", + "safeName": "FIRST_NAME" + }, + "pascalCase": { + "unsafeName": "FirstName", + "safeName": "FirstName" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "lastName", + "name": { + "originalName": "lastName", + "camelCase": { + "unsafeName": "lastName", + "safeName": "lastName" + }, + "snakeCase": { + "unsafeName": "last_name", + "safeName": "last_name" + }, + "screamingSnakeCase": { + "unsafeName": "LAST_NAME", + "safeName": "LAST_NAME" + }, + "pascalCase": { + "unsafeName": "LastName", + "safeName": "LastName" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "email", + "name": { + "originalName": "email", + "camelCase": { + "unsafeName": "email", + "safeName": "email" + }, + "snakeCase": { + "unsafeName": "email", + "safeName": "email" + }, + "screamingSnakeCase": { + "unsafeName": "EMAIL", + "safeName": "EMAIL" + }, + "pascalCase": { + "unsafeName": "Email", + "safeName": "Email" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "acceptWelcomeEmail", + "name": { + "originalName": "acceptWelcomeEmail", + "camelCase": { + "unsafeName": "acceptWelcomeEmail", + "safeName": "acceptWelcomeEmail" + }, + "snakeCase": { + "unsafeName": "accept_welcome_email", + "safeName": "accept_welcome_email" + }, + "screamingSnakeCase": { + "unsafeName": "ACCEPT_WELCOME_EMAIL", + "safeName": "ACCEPT_WELCOME_EMAIL" + }, + "pascalCase": { + "unsafeName": "AcceptWelcomeEmail", + "safeName": "AcceptWelcomeEmail" + } + } + }, + "typeReference": { + "value": { + "value": "BOOLEAN", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "originChannelId", + "name": { + "originalName": "originChannelId", + "camelCase": { + "unsafeName": "originChannelID", + "safeName": "originChannelID" + }, + "snakeCase": { + "unsafeName": "origin_channel_id", + "safeName": "origin_channel_id" + }, + "screamingSnakeCase": { + "unsafeName": "ORIGIN_CHANNEL_ID", + "safeName": "ORIGIN_CHANNEL_ID" + }, + "pascalCase": { + "unsafeName": "OriginChannelID", + "safeName": "OriginChannelID" + } + } + }, + "typeReference": { + "value": { + "value": "INTEGER", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "channelIds", + "name": { + "originalName": "channelIds", + "camelCase": { + "unsafeName": "channelIDs", + "safeName": "channelIDs" + }, + "snakeCase": { + "unsafeName": "channel_ids", + "safeName": "channel_ids" + }, + "screamingSnakeCase": { + "unsafeName": "CHANNEL_IDS", + "safeName": "CHANNEL_IDS" + }, + "pascalCase": { + "unsafeName": "ChannelIDs", + "safeName": "ChannelIDs" + } + } + }, + "typeReference": { + "value": { + "value": "INTEGER", + "type": "primitive" + }, + "type": "list" + } + } + ], + "type": "properties" + }, + "metadata": { + "includePathParameters": false, + "onlyPathParameters": false + }, + "type": "inlined" + }, + "response": { + "type": "json" + }, + "examples": [] + }, + "endpoint_.createCompany": { + "declaration": { + "name": { + "originalName": "createCompany", + "camelCase": { + "unsafeName": "createCompany", + "safeName": "createCompany" + }, + "snakeCase": { + "unsafeName": "create_company", + "safeName": "create_company" + }, + "screamingSnakeCase": { + "unsafeName": "CREATE_COMPANY", + "safeName": "CREATE_COMPANY" + }, + "pascalCase": { + "unsafeName": "CreateCompany", + "safeName": "CreateCompany" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "location": { + "method": "POST", + "path": "/companies" + }, + "request": { + "declaration": { + "name": { + "originalName": "CompanyPost", + "camelCase": { + "unsafeName": "companyPost", + "safeName": "companyPost" + }, + "snakeCase": { + "unsafeName": "company_post", + "safeName": "company_post" + }, + "screamingSnakeCase": { + "unsafeName": "COMPANY_POST", + "safeName": "COMPANY_POST" + }, + "pascalCase": { + "unsafeName": "CompanyPost", + "safeName": "CompanyPost" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "pathParameters": [], + "queryParameters": [], + "headers": [], + "body": { + "value": [ + { + "name": { + "wireValue": "companyName", + "name": { + "originalName": "companyName", + "camelCase": { + "unsafeName": "companyName", + "safeName": "companyName" + }, + "snakeCase": { + "unsafeName": "company_name", + "safeName": "company_name" + }, + "screamingSnakeCase": { + "unsafeName": "COMPANY_NAME", + "safeName": "COMPANY_NAME" + }, + "pascalCase": { + "unsafeName": "CompanyName", + "safeName": "CompanyName" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "companyPhone", + "name": { + "originalName": "companyPhone", + "camelCase": { + "unsafeName": "companyPhone", + "safeName": "companyPhone" + }, + "snakeCase": { + "unsafeName": "company_phone", + "safeName": "company_phone" + }, + "screamingSnakeCase": { + "unsafeName": "COMPANY_PHONE", + "safeName": "COMPANY_PHONE" + }, + "pascalCase": { + "unsafeName": "CompanyPhone", + "safeName": "CompanyPhone" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "country", + "name": { + "originalName": "country", + "camelCase": { + "unsafeName": "country", + "safeName": "country" + }, + "snakeCase": { + "unsafeName": "country", + "safeName": "country" + }, + "screamingSnakeCase": { + "unsafeName": "COUNTRY", + "safeName": "COUNTRY" + }, + "pascalCase": { + "unsafeName": "Country", + "safeName": "Country" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "state", + "name": { + "originalName": "state", + "camelCase": { + "unsafeName": "state", + "safeName": "state" + }, + "snakeCase": { + "unsafeName": "state", + "safeName": "state" + }, + "screamingSnakeCase": { + "unsafeName": "STATE", + "safeName": "STATE" + }, + "pascalCase": { + "unsafeName": "State", + "safeName": "State" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "city", + "name": { + "originalName": "city", + "camelCase": { + "unsafeName": "city", + "safeName": "city" + }, + "snakeCase": { + "unsafeName": "city", + "safeName": "city" + }, + "screamingSnakeCase": { + "unsafeName": "CITY", + "safeName": "CITY" + }, + "pascalCase": { + "unsafeName": "City", + "safeName": "City" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "taxId", + "name": { + "originalName": "taxId", + "camelCase": { + "unsafeName": "taxID", + "safeName": "taxID" + }, + "snakeCase": { + "unsafeName": "tax_id", + "safeName": "tax_id" + }, + "screamingSnakeCase": { + "unsafeName": "TAX_ID", + "safeName": "TAX_ID" + }, + "pascalCase": { + "unsafeName": "TaxID", + "safeName": "TaxID" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "type": "properties" + }, + "metadata": { + "includePathParameters": false, + "onlyPathParameters": false + }, + "type": "inlined" + }, + "response": { + "type": "json" + }, + "examples": [] + }, + "endpoint_.createPlant": { + "declaration": { + "name": { + "originalName": "createPlant", + "camelCase": { + "unsafeName": "createPlant", + "safeName": "createPlant" + }, + "snakeCase": { + "unsafeName": "create_plant", + "safeName": "create_plant" + }, + "screamingSnakeCase": { + "unsafeName": "CREATE_PLANT", + "safeName": "CREATE_PLANT" + }, + "pascalCase": { + "unsafeName": "CreatePlant", + "safeName": "CreatePlant" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "location": { + "method": "POST", + "path": "/plants" + }, + "request": { + "pathParameters": [], + "body": { + "value": { + "value": "type_:PlantRecord", + "type": "named" + }, + "type": "typeReference" + }, + "type": "body" + }, + "response": { + "type": "json" + }, + "examples": [] + } + }, + "pathParameters": [], + "environments": { + "defaultEnvironment": "Default", + "environments": { + "environments": [ + { + "id": "Default", + "name": { + "originalName": "Default", + "camelCase": { + "unsafeName": "default", + "safeName": "default" + }, + "snakeCase": { + "unsafeName": "default", + "safeName": "default" + }, + "screamingSnakeCase": { + "unsafeName": "DEFAULT", + "safeName": "DEFAULT" + }, + "pascalCase": { + "unsafeName": "Default", + "safeName": "Default" + } + }, + "url": "https://api.example.com" + } + ], + "type": "singleBaseUrl" + } + } + }, + "apiPlayground": true, + "casingsConfig": { + "smartCasing": true + }, + "subpackages": {}, + "rootPackage": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "service": "service_", + "types": [ + "type_:UserStrict", + "type_:UserBase", + "type_:AddressBase", + "type_:CompanyBase", + "type_:Identifiable", + "type_:Describable", + "type_:PlantBaseWateringFrequency", + "type_:PlantBase", + "type_:PlantRecord" + ], + "errors": [], + "subpackages": [], + "hasEndpointsInTree": true, + "hasWebSocketInTree": false + }, + "sdkConfig": { + "isAuthMandatory": false, + "hasStreamingEndpoints": false, + "hasPaginatedEndpoints": false, + "hasFileDownloadEndpoints": false, + "platformHeaders": { + "language": "X-Fern-Language", + "sdkName": "X-Fern-SDK-Name", + "sdkVersion": "X-Fern-SDK-Version" + } + } +} \ No newline at end of file diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/url-reference.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/url-reference.json index d9591a802d3b..f4d26f1b94d4 100644 --- a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/url-reference.json +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/url-reference.json @@ -1,7 +1,6 @@ { "selfHosted": false, "apiName": "api", - "apiDisplayName": "URL Reference API", "auth": { "requirement": "ALL", "schemes": [] @@ -211,241 +210,6 @@ "userProvidedExamples": [], "autogeneratedExamples": [] }, - "type_:Mammal": { - "name": { - "name": "Mammal", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Mammal" - }, - "shape": { - "members": [ - { - "type": { - "name": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "displayName": "Pet", - "typeId": "type_:Pet", - "type": "named" - } - }, - { - "type": { - "name": "User", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "displayName": "User", - "typeId": "type_:User", - "type": "named" - } - } - ], - "type": "undiscriminatedUnion" - }, - "referencedTypes": {}, - "encoding": { - "json": {} - }, - "userProvidedExamples": [], - "autogeneratedExamples": [] - }, - "type_:PetStatus": { - "docs": "pet status in the store", - "inline": true, - "name": { - "name": "PetStatus", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:PetStatus" - }, - "shape": { - "values": [ - { - "name": "available" - }, - { - "name": "pending" - }, - { - "name": "sold" - } - ], - "type": "enum" - }, - "referencedTypes": {}, - "encoding": { - "json": {} - }, - "userProvidedExamples": [], - "autogeneratedExamples": [] - }, - "type_:Pet": { - "docs": "A pet for sale in the pet store", - "name": { - "name": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Pet" - }, - "shape": { - "extends": [], - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "category", - "valueType": { - "container": { - "optional": { - "name": "Category", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Category", - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "name", - "valueType": { - "primitive": { - "v1": "STRING", - "v2": { - "type": "string" - } - }, - "type": "primitive" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "photoUrls", - "valueType": { - "container": { - "list": { - "primitive": { - "v1": "STRING", - "v2": { - "type": "string" - } - }, - "type": "primitive" - }, - "type": "list" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "tags", - "valueType": { - "container": { - "optional": { - "container": { - "list": { - "name": "Tag", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Tag", - "type": "named" - }, - "type": "list" - }, - "type": "container" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "docs": "pet status in the store", - "name": "status", - "valueType": { - "container": { - "optional": { - "name": "PetStatus", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:PetStatus", - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - } - ], - "extraProperties": false, - "extendedProperties": [], - "type": "object" - }, - "referencedTypes": {}, - "encoding": { - "json": {} - }, - "userProvidedExamples": [], - "autogeneratedExamples": [] - }, "type_:PetOwner": { "name": { "name": "PetOwner", @@ -470,2392 +234,419 @@ }, "userProvidedExamples": [], "autogeneratedExamples": [] - }, - "type_:OrderStatus": { - "docs": "Order Status", - "inline": true, - "name": { - "name": "OrderStatus", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:OrderStatus" - }, - "shape": { - "values": [ - { - "name": "placed" - }, - { - "name": "approved" - }, - { - "name": "delivered" - } - ], - "type": "enum" - }, - "referencedTypes": {}, - "encoding": { - "json": {} - }, - "userProvidedExamples": [], - "autogeneratedExamples": [] - }, - "type_:Order": { - "docs": "An order for a pets from the pet store", - "name": { - "name": "Order", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Order" + } + }, + "errors": {}, + "services": {}, + "constants": { + "errorInstanceIdKey": "errorInstanceId" + }, + "errorDiscriminationStrategy": { + "type": "statusCode" + }, + "pathParameters": [], + "variables": [], + "serviceTypeReferenceInfo": { + "typesReferencedOnlyByService": {}, + "sharedTypes": [ + "type_petowners:PetownersSubscribeParamsChannel", + "type_petowners:PetownersSubscribeParamsData", + "type_petowners:PetownersSubscribeParams", + "type_petowners:PetownersSubscribe", + "type_:PetOwner" + ] + }, + "webhookGroups": {}, + "websocketChannels": { + "channel_petowners": { + "path": { + "head": "/petowners", + "parts": [] }, - "shape": { - "extends": [], - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" + "auth": false, + "name": "petowners", + "headers": [], + "docs": "Private websocket channel for receiving updates about pet owners and their pets", + "pathParameters": [], + "queryParameters": [], + "messages": [ + { + "type": "subscribe", + "origin": "server", + "body": { + "bodyType": { + "name": "PetownersSubscribe", + "fernFilepath": { + "allParts": [ + "petowners" + ], + "packagePath": [], + "file": "petowners" }, - "type": "container" + "typeId": "type_petowners:PetownersSubscribe", + "type": "named" }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "petId", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "quantity", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "INTEGER", - "v2": { - "type": "integer" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "shipDate", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "DATE_TIME" - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "docs": "Order Status", - "name": "status", - "valueType": { - "container": { - "optional": { - "name": "OrderStatus", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:OrderStatus", - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "complete", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "BOOLEAN", - "v2": { - "default": false, - "type": "boolean" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "defaultValue": false, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - } - ], - "extraProperties": false, - "extendedProperties": [], - "type": "object" - }, - "referencedTypes": {}, - "encoding": { - "json": {} - }, - "userProvidedExamples": [], - "autogeneratedExamples": [] - }, - "type_:Category": { - "docs": "A category for a pet", - "name": { - "name": "Category", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Category" - }, - "shape": { - "extends": [], - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "name", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - } - ], - "extraProperties": false, - "extendedProperties": [], - "type": "object" - }, - "referencedTypes": {}, - "encoding": { - "json": {} - }, - "userProvidedExamples": [], - "autogeneratedExamples": [] - }, - "type_:Tag": { - "docs": "A tag for a pet", - "name": { - "name": "Tag", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Tag" - }, - "shape": { - "extends": [], - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "name", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } + "type": "reference" } - ], - "extraProperties": false, - "extendedProperties": [], - "type": "object" - }, - "referencedTypes": {}, - "encoding": { - "json": {} - }, - "userProvidedExamples": [], - "autogeneratedExamples": [] - }, - "type_:User": { - "docs": "A User who is purchasing from the pet store", - "name": { - "name": "User", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:User" - }, - "shape": { - "extends": [], - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "username", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "firstName", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "lastName", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "email", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "password", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "name": "phone", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - }, - { - "docs": "User Status", - "name": "userStatus", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "INTEGER", - "v2": { - "type": "integer" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - } - ], - "extraProperties": false, - "extendedProperties": [], - "type": "object" - }, - "referencedTypes": {}, - "encoding": { - "json": {} - }, - "userProvidedExamples": [], - "autogeneratedExamples": [] - } - }, - "errors": {}, - "services": { - "service_pet": { - "name": { - "fernFilepath": { - "allParts": [ - "pet" - ], - "packagePath": [], - "file": "pet" - } - }, - "basePath": { - "head": "", - "parts": [] - }, - "headers": [], - "pathParameters": [], - "encoding": { - "json": {} - }, - "transport": { - "type": "http" - }, - "endpoints": [ - { - "id": "endpoint_pet.updateAnExistingPet", - "name": "updateAnExistingPet", - "displayName": "Update an existing pet", - "auth": false, - "idempotent": false, - "method": "PUT", - "path": { - "head": "/pet", - "parts": [] - }, - "fullPath": { - "head": "pet", - "parts": [] - }, - "pathParameters": [], - "allPathParameters": [], - "queryParameters": [], - "headers": [], - "requestBody": { - "requestBodyType": { - "name": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Pet", - "type": "named" - }, - "contentType": "application/json", - "type": "reference" - }, - "sdkRequest": { - "shape": { - "value": { - "requestBodyType": { - "name": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Pet", - "type": "named" - }, - "type": "typeReference" - }, - "type": "justRequestBody" - }, - "requestParameterName": "request" - }, - "response": { - "body": { - "value": { - "docs": "A list of pets", - "responseBodyType": { - "name": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Pet", - "type": "named" - }, - "type": "response" - }, - "type": "json" - }, - "statusCode": 200, - "docs": "A list of pets" - }, - "errors": [], - "userSpecifiedExamples": [], - "autogeneratedExamples": [], - "responseHeaders": [] - } - ] - }, - "service_order": { - "name": { - "fernFilepath": { - "allParts": [ - "order" - ], - "packagePath": [], - "file": "order" - } - }, - "basePath": { - "head": "", - "parts": [] - }, - "headers": [], - "pathParameters": [], - "encoding": { - "json": {} - }, - "transport": { - "type": "http" - }, - "endpoints": [ - { - "id": "endpoint_order.getAnOrder", - "name": "getAnOrder", - "displayName": "Get an order", - "auth": false, - "idempotent": false, - "method": "GET", - "path": { - "head": "/order", - "parts": [] - }, - "fullPath": { - "head": "order", - "parts": [] - }, - "pathParameters": [], - "allPathParameters": [], - "queryParameters": [], - "headers": [], - "response": { - "body": { - "value": { - "docs": "An order object", - "responseBodyType": { - "name": "Order", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "typeId": "type_:Order", - "type": "named" - }, - "type": "response" - }, - "type": "json" - }, - "statusCode": 200, - "docs": "An order object" - }, - "errors": [], - "userSpecifiedExamples": [], - "autogeneratedExamples": [], - "responseHeaders": [] - } - ] - } - }, - "constants": { - "errorInstanceIdKey": "errorInstanceId" - }, - "errorDiscriminationStrategy": { - "type": "statusCode" - }, - "pathParameters": [], - "variables": [], - "serviceTypeReferenceInfo": { - "typesReferencedOnlyByService": { - "service_pet": [ - "type_:PetStatus", - "type_:Pet", - "type_:Category", - "type_:Tag" - ], - "service_order": [ - "type_:OrderStatus", - "type_:Order" - ] - }, - "sharedTypes": [ - "type_petowners:PetownersSubscribeParamsChannel", - "type_petowners:PetownersSubscribeParamsData", - "type_petowners:PetownersSubscribeParams", - "type_petowners:PetownersSubscribe", - "type_:Mammal", - "type_:PetOwner", - "type_:User" - ] - }, - "webhookGroups": {}, - "websocketChannels": { - "channel_petowners": { - "path": { - "head": "/petowners", - "parts": [] - }, - "auth": false, - "name": "petowners", - "headers": [], - "docs": "Private websocket channel for receiving updates about pet owners and their pets", - "pathParameters": [], - "queryParameters": [], - "messages": [ - { - "type": "subscribe", - "origin": "server", - "body": { - "bodyType": { - "name": "PetownersSubscribe", - "fernFilepath": { - "allParts": [ - "petowners" - ], - "packagePath": [], - "file": "petowners" - }, - "typeId": "type_petowners:PetownersSubscribe", - "type": "named" - }, - "type": "reference" - } - } - ], - "examples": [ - { - "url": "/petowners", - "pathParameters": [], - "headers": [], - "queryParameters": [], - "messages": [ - { - "type": "subscribe", - "body": { - "shape": { - "typeName": { - "typeId": "type_petowners:PetownersSubscribe", - "fernFilepath": { - "allParts": [ - "petowners" - ], - "packagePath": [], - "file": "petowners" - }, - "name": "PetownersSubscribe" + } + ], + "examples": [ + { + "url": "/petowners", + "pathParameters": [], + "headers": [], + "queryParameters": [], + "messages": [ + { + "type": "subscribe", + "body": { + "shape": { + "typeName": { + "typeId": "type_petowners:PetownersSubscribe", + "fernFilepath": { + "allParts": [ + "petowners" + ], + "packagePath": [], + "file": "petowners" + }, + "name": "PetownersSubscribe" }, "shape": { "properties": [], - "type": "object" - }, - "type": "named" - }, - "jsonExample": {}, - "type": "reference" - } - } - ] - } - ], - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - } - }, - "dynamic": { - "version": "1.0.0", - "types": { - "type_petowners:PetownersSubscribeParamsChannel": { - "declaration": { - "name": { - "originalName": "PetownersSubscribeParamsChannel", - "camelCase": { - "unsafeName": "petownersSubscribeParamsChannel", - "safeName": "petownersSubscribeParamsChannel" - }, - "snakeCase": { - "unsafeName": "petowners_subscribe_params_channel", - "safeName": "petowners_subscribe_params_channel" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS_CHANNEL", - "safeName": "PETOWNERS_SUBSCRIBE_PARAMS_CHANNEL" - }, - "pascalCase": { - "unsafeName": "PetownersSubscribeParamsChannel", - "safeName": "PetownersSubscribeParamsChannel" - } - }, - "fernFilepath": { - "allParts": [ - { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" - }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" - } - } - ], - "packagePath": [], - "file": { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" - }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" - } - } - } - }, - "values": [ - { - "wireValue": "petowners", - "name": { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" - }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" - } - } - } - ], - "type": "enum" - }, - "type_petowners:PetownersSubscribeParamsData": { - "declaration": { - "name": { - "originalName": "PetownersSubscribeParamsData", - "camelCase": { - "unsafeName": "petownersSubscribeParamsData", - "safeName": "petownersSubscribeParamsData" - }, - "snakeCase": { - "unsafeName": "petowners_subscribe_params_data", - "safeName": "petowners_subscribe_params_data" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS_DATA", - "safeName": "PETOWNERS_SUBSCRIBE_PARAMS_DATA" - }, - "pascalCase": { - "unsafeName": "PetownersSubscribeParamsData", - "safeName": "PetownersSubscribeParamsData" - } - }, - "fernFilepath": { - "allParts": [ - { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" - }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" - } - } - ], - "packagePath": [], - "file": { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" - }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" - } - } - } - }, - "properties": [ - { - "name": { - "wireValue": "petOwner", - "name": { - "originalName": "petOwner", - "camelCase": { - "unsafeName": "petOwner", - "safeName": "petOwner" - }, - "snakeCase": { - "unsafeName": "pet_owner", - "safeName": "pet_owner" - }, - "screamingSnakeCase": { - "unsafeName": "PET_OWNER", - "safeName": "PET_OWNER" - }, - "pascalCase": { - "unsafeName": "PetOwner", - "safeName": "PetOwner" - } - } - }, - "typeReference": { - "value": "type_:PetOwner", - "type": "named" - } - } - ], - "additionalProperties": false, - "type": "object" - }, - "type_petowners:PetownersSubscribeParams": { - "declaration": { - "name": { - "originalName": "PetownersSubscribeParams", - "camelCase": { - "unsafeName": "petownersSubscribeParams", - "safeName": "petownersSubscribeParams" - }, - "snakeCase": { - "unsafeName": "petowners_subscribe_params", - "safeName": "petowners_subscribe_params" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS", - "safeName": "PETOWNERS_SUBSCRIBE_PARAMS" - }, - "pascalCase": { - "unsafeName": "PetownersSubscribeParams", - "safeName": "PetownersSubscribeParams" - } - }, - "fernFilepath": { - "allParts": [ - { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" - }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" - } - } - ], - "packagePath": [], - "file": { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" - }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" - } - } - } - }, - "properties": [ - { - "name": { - "wireValue": "channel", - "name": { - "originalName": "channel", - "camelCase": { - "unsafeName": "channel", - "safeName": "channel" - }, - "snakeCase": { - "unsafeName": "channel", - "safeName": "channel" - }, - "screamingSnakeCase": { - "unsafeName": "CHANNEL", - "safeName": "CHANNEL" - }, - "pascalCase": { - "unsafeName": "Channel", - "safeName": "Channel" - } - } - }, - "typeReference": { - "value": { - "value": "type_petowners:PetownersSubscribeParamsChannel", - "type": "named" - }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "data", - "name": { - "originalName": "data", - "camelCase": { - "unsafeName": "data", - "safeName": "data" - }, - "snakeCase": { - "unsafeName": "data", - "safeName": "data" - }, - "screamingSnakeCase": { - "unsafeName": "DATA", - "safeName": "DATA" - }, - "pascalCase": { - "unsafeName": "Data", - "safeName": "Data" - } - } - }, - "typeReference": { - "value": { - "value": "type_petowners:PetownersSubscribeParamsData", - "type": "named" - }, - "type": "optional" - } - } - ], - "additionalProperties": false, - "type": "object" - }, - "type_petowners:PetownersSubscribe": { - "declaration": { - "name": { - "originalName": "PetownersSubscribe", - "camelCase": { - "unsafeName": "petownersSubscribe", - "safeName": "petownersSubscribe" - }, - "snakeCase": { - "unsafeName": "petowners_subscribe", - "safeName": "petowners_subscribe" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS_SUBSCRIBE", - "safeName": "PETOWNERS_SUBSCRIBE" - }, - "pascalCase": { - "unsafeName": "PetownersSubscribe", - "safeName": "PetownersSubscribe" - } - }, - "fernFilepath": { - "allParts": [ - { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" - }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" - } - } - ], - "packagePath": [], - "file": { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" - }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" - } - } - } - }, - "properties": [ - { - "name": { - "wireValue": "params", - "name": { - "originalName": "params", - "camelCase": { - "unsafeName": "params", - "safeName": "params" - }, - "snakeCase": { - "unsafeName": "params", - "safeName": "params" - }, - "screamingSnakeCase": { - "unsafeName": "PARAMS", - "safeName": "PARAMS" - }, - "pascalCase": { - "unsafeName": "Params", - "safeName": "Params" - } - } - }, - "typeReference": { - "value": { - "value": "type_petowners:PetownersSubscribeParams", - "type": "named" - }, - "type": "optional" - } - } - ], - "additionalProperties": false, - "type": "object" - }, - "type_:Mammal": { - "declaration": { - "name": { - "originalName": "Mammal", - "camelCase": { - "unsafeName": "mammal", - "safeName": "mammal" - }, - "snakeCase": { - "unsafeName": "mammal", - "safeName": "mammal" - }, - "screamingSnakeCase": { - "unsafeName": "MAMMAL", - "safeName": "MAMMAL" - }, - "pascalCase": { - "unsafeName": "Mammal", - "safeName": "Mammal" - } - }, - "fernFilepath": { - "allParts": [], - "packagePath": [] - } - }, - "types": [ - { - "value": "type_:Pet", - "type": "named" - }, - { - "value": "type_:User", - "type": "named" - } - ], - "type": "undiscriminatedUnion" - }, - "type_:PetStatus": { - "declaration": { - "name": { - "originalName": "PetStatus", - "camelCase": { - "unsafeName": "petStatus", - "safeName": "petStatus" - }, - "snakeCase": { - "unsafeName": "pet_status", - "safeName": "pet_status" - }, - "screamingSnakeCase": { - "unsafeName": "PET_STATUS", - "safeName": "PET_STATUS" - }, - "pascalCase": { - "unsafeName": "PetStatus", - "safeName": "PetStatus" - } - }, - "fernFilepath": { - "allParts": [], - "packagePath": [] - } - }, - "values": [ - { - "wireValue": "available", - "name": { - "originalName": "available", - "camelCase": { - "unsafeName": "available", - "safeName": "available" - }, - "snakeCase": { - "unsafeName": "available", - "safeName": "available" - }, - "screamingSnakeCase": { - "unsafeName": "AVAILABLE", - "safeName": "AVAILABLE" - }, - "pascalCase": { - "unsafeName": "Available", - "safeName": "Available" - } - } - }, - { - "wireValue": "pending", - "name": { - "originalName": "pending", - "camelCase": { - "unsafeName": "pending", - "safeName": "pending" - }, - "snakeCase": { - "unsafeName": "pending", - "safeName": "pending" - }, - "screamingSnakeCase": { - "unsafeName": "PENDING", - "safeName": "PENDING" - }, - "pascalCase": { - "unsafeName": "Pending", - "safeName": "Pending" - } - } - }, - { - "wireValue": "sold", - "name": { - "originalName": "sold", - "camelCase": { - "unsafeName": "sold", - "safeName": "sold" - }, - "snakeCase": { - "unsafeName": "sold", - "safeName": "sold" - }, - "screamingSnakeCase": { - "unsafeName": "SOLD", - "safeName": "SOLD" - }, - "pascalCase": { - "unsafeName": "Sold", - "safeName": "Sold" - } - } - } - ], - "type": "enum" - }, - "type_:Pet": { - "declaration": { - "name": { - "originalName": "Pet", - "camelCase": { - "unsafeName": "pet", - "safeName": "pet" - }, - "snakeCase": { - "unsafeName": "pet", - "safeName": "pet" - }, - "screamingSnakeCase": { - "unsafeName": "PET", - "safeName": "PET" - }, - "pascalCase": { - "unsafeName": "Pet", - "safeName": "Pet" - } - }, - "fernFilepath": { - "allParts": [], - "packagePath": [] - } - }, - "properties": [ - { - "name": { - "wireValue": "id", - "name": { - "originalName": "id", - "camelCase": { - "unsafeName": "id", - "safeName": "id" - }, - "snakeCase": { - "unsafeName": "id", - "safeName": "id" - }, - "screamingSnakeCase": { - "unsafeName": "ID", - "safeName": "ID" - }, - "pascalCase": { - "unsafeName": "ID", - "safeName": "ID" - } - } - }, - "typeReference": { - "value": { - "value": "LONG", - "type": "primitive" - }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "category", - "name": { - "originalName": "category", - "camelCase": { - "unsafeName": "category", - "safeName": "category" - }, - "snakeCase": { - "unsafeName": "category", - "safeName": "category" - }, - "screamingSnakeCase": { - "unsafeName": "CATEGORY", - "safeName": "CATEGORY" - }, - "pascalCase": { - "unsafeName": "Category", - "safeName": "Category" - } - } - }, - "typeReference": { - "value": { - "value": "type_:Category", - "type": "named" - }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "name", - "name": { - "originalName": "name", - "camelCase": { - "unsafeName": "name", - "safeName": "name" - }, - "snakeCase": { - "unsafeName": "name", - "safeName": "name" - }, - "screamingSnakeCase": { - "unsafeName": "NAME", - "safeName": "NAME" - }, - "pascalCase": { - "unsafeName": "Name", - "safeName": "Name" - } - } - }, - "typeReference": { - "value": "STRING", - "type": "primitive" - } - }, - { - "name": { - "wireValue": "photoUrls", - "name": { - "originalName": "photoUrls", - "camelCase": { - "unsafeName": "photoURLs", - "safeName": "photoURLs" - }, - "snakeCase": { - "unsafeName": "photo_urls", - "safeName": "photo_urls" - }, - "screamingSnakeCase": { - "unsafeName": "PHOTO_URLS", - "safeName": "PHOTO_URLS" - }, - "pascalCase": { - "unsafeName": "PhotoURLs", - "safeName": "PhotoURLs" - } - } - }, - "typeReference": { - "value": { - "value": "STRING", - "type": "primitive" - }, - "type": "list" - } - }, - { - "name": { - "wireValue": "tags", - "name": { - "originalName": "tags", - "camelCase": { - "unsafeName": "tags", - "safeName": "tags" - }, - "snakeCase": { - "unsafeName": "tags", - "safeName": "tags" - }, - "screamingSnakeCase": { - "unsafeName": "TAGS", - "safeName": "TAGS" - }, - "pascalCase": { - "unsafeName": "Tags", - "safeName": "Tags" - } - } - }, - "typeReference": { - "value": { - "value": { - "value": "type_:Tag", - "type": "named" - }, - "type": "list" - }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "status", - "name": { - "originalName": "status", - "camelCase": { - "unsafeName": "status", - "safeName": "status" - }, - "snakeCase": { - "unsafeName": "status", - "safeName": "status" - }, - "screamingSnakeCase": { - "unsafeName": "STATUS", - "safeName": "STATUS" - }, - "pascalCase": { - "unsafeName": "Status", - "safeName": "Status" - } - } - }, - "typeReference": { - "value": { - "value": "type_:PetStatus", - "type": "named" - }, - "type": "optional" - } - } - ], - "additionalProperties": false, - "type": "object" - }, - "type_:PetOwner": { - "declaration": { - "name": { - "originalName": "PetOwner", - "camelCase": { - "unsafeName": "petOwner", - "safeName": "petOwner" - }, - "snakeCase": { - "unsafeName": "pet_owner", - "safeName": "pet_owner" - }, - "screamingSnakeCase": { - "unsafeName": "PET_OWNER", - "safeName": "PET_OWNER" - }, - "pascalCase": { - "unsafeName": "PetOwner", - "safeName": "PetOwner" - } - }, - "fernFilepath": { - "allParts": [], - "packagePath": [] - } - }, - "typeReference": { - "type": "unknown" - }, - "type": "alias" - }, - "type_:OrderStatus": { - "declaration": { - "name": { - "originalName": "OrderStatus", - "camelCase": { - "unsafeName": "orderStatus", - "safeName": "orderStatus" - }, - "snakeCase": { - "unsafeName": "order_status", - "safeName": "order_status" - }, - "screamingSnakeCase": { - "unsafeName": "ORDER_STATUS", - "safeName": "ORDER_STATUS" - }, - "pascalCase": { - "unsafeName": "OrderStatus", - "safeName": "OrderStatus" - } - }, - "fernFilepath": { - "allParts": [], - "packagePath": [] - } - }, - "values": [ - { - "wireValue": "placed", - "name": { - "originalName": "placed", - "camelCase": { - "unsafeName": "placed", - "safeName": "placed" - }, - "snakeCase": { - "unsafeName": "placed", - "safeName": "placed" - }, - "screamingSnakeCase": { - "unsafeName": "PLACED", - "safeName": "PLACED" - }, - "pascalCase": { - "unsafeName": "Placed", - "safeName": "Placed" - } - } - }, - { - "wireValue": "approved", - "name": { - "originalName": "approved", - "camelCase": { - "unsafeName": "approved", - "safeName": "approved" - }, - "snakeCase": { - "unsafeName": "approved", - "safeName": "approved" - }, - "screamingSnakeCase": { - "unsafeName": "APPROVED", - "safeName": "APPROVED" - }, - "pascalCase": { - "unsafeName": "Approved", - "safeName": "Approved" - } - } - }, - { - "wireValue": "delivered", - "name": { - "originalName": "delivered", - "camelCase": { - "unsafeName": "delivered", - "safeName": "delivered" - }, - "snakeCase": { - "unsafeName": "delivered", - "safeName": "delivered" - }, - "screamingSnakeCase": { - "unsafeName": "DELIVERED", - "safeName": "DELIVERED" - }, - "pascalCase": { - "unsafeName": "Delivered", - "safeName": "Delivered" - } - } - } - ], - "type": "enum" - }, - "type_:Order": { - "declaration": { - "name": { - "originalName": "Order", - "camelCase": { - "unsafeName": "order", - "safeName": "order" - }, - "snakeCase": { - "unsafeName": "order", - "safeName": "order" - }, - "screamingSnakeCase": { - "unsafeName": "ORDER", - "safeName": "ORDER" - }, - "pascalCase": { - "unsafeName": "Order", - "safeName": "Order" - } - }, - "fernFilepath": { - "allParts": [], - "packagePath": [] - } - }, - "properties": [ - { - "name": { - "wireValue": "id", - "name": { - "originalName": "id", - "camelCase": { - "unsafeName": "id", - "safeName": "id" - }, - "snakeCase": { - "unsafeName": "id", - "safeName": "id" - }, - "screamingSnakeCase": { - "unsafeName": "ID", - "safeName": "ID" - }, - "pascalCase": { - "unsafeName": "ID", - "safeName": "ID" - } - } - }, - "typeReference": { - "value": { - "value": "LONG", - "type": "primitive" - }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "petId", - "name": { - "originalName": "petId", - "camelCase": { - "unsafeName": "petID", - "safeName": "petID" - }, - "snakeCase": { - "unsafeName": "pet_id", - "safeName": "pet_id" - }, - "screamingSnakeCase": { - "unsafeName": "PET_ID", - "safeName": "PET_ID" - }, - "pascalCase": { - "unsafeName": "PetID", - "safeName": "PetID" - } - } - }, - "typeReference": { - "value": { - "value": "LONG", - "type": "primitive" - }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "quantity", - "name": { - "originalName": "quantity", - "camelCase": { - "unsafeName": "quantity", - "safeName": "quantity" - }, - "snakeCase": { - "unsafeName": "quantity", - "safeName": "quantity" - }, - "screamingSnakeCase": { - "unsafeName": "QUANTITY", - "safeName": "QUANTITY" - }, - "pascalCase": { - "unsafeName": "Quantity", - "safeName": "Quantity" - } - } - }, - "typeReference": { - "value": { - "value": "INTEGER", - "type": "primitive" - }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "shipDate", - "name": { - "originalName": "shipDate", - "camelCase": { - "unsafeName": "shipDate", - "safeName": "shipDate" - }, - "snakeCase": { - "unsafeName": "ship_date", - "safeName": "ship_date" - }, - "screamingSnakeCase": { - "unsafeName": "SHIP_DATE", - "safeName": "SHIP_DATE" - }, - "pascalCase": { - "unsafeName": "ShipDate", - "safeName": "ShipDate" - } - } - }, - "typeReference": { - "value": { - "value": "DATE_TIME", - "type": "primitive" - }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "status", - "name": { - "originalName": "status", - "camelCase": { - "unsafeName": "status", - "safeName": "status" - }, - "snakeCase": { - "unsafeName": "status", - "safeName": "status" - }, - "screamingSnakeCase": { - "unsafeName": "STATUS", - "safeName": "STATUS" - }, - "pascalCase": { - "unsafeName": "Status", - "safeName": "Status" - } - } - }, - "typeReference": { - "value": { - "value": "type_:OrderStatus", - "type": "named" - }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "complete", - "name": { - "originalName": "complete", - "camelCase": { - "unsafeName": "complete", - "safeName": "complete" - }, - "snakeCase": { - "unsafeName": "complete", - "safeName": "complete" - }, - "screamingSnakeCase": { - "unsafeName": "COMPLETE", - "safeName": "COMPLETE" + "type": "object" + }, + "type": "named" }, - "pascalCase": { - "unsafeName": "Complete", - "safeName": "Complete" - } + "jsonExample": {}, + "type": "reference" } - }, - "typeReference": { - "value": { - "value": "BOOLEAN", - "type": "primitive" - }, - "type": "optional" } - } - ], - "additionalProperties": false, - "type": "object" - }, - "type_:Category": { + ] + } + ], + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + }, + "dynamic": { + "version": "1.0.0", + "types": { + "type_petowners:PetownersSubscribeParamsChannel": { "declaration": { "name": { - "originalName": "Category", + "originalName": "PetownersSubscribeParamsChannel", "camelCase": { - "unsafeName": "category", - "safeName": "category" + "unsafeName": "petownersSubscribeParamsChannel", + "safeName": "petownersSubscribeParamsChannel" }, "snakeCase": { - "unsafeName": "category", - "safeName": "category" + "unsafeName": "petowners_subscribe_params_channel", + "safeName": "petowners_subscribe_params_channel" }, "screamingSnakeCase": { - "unsafeName": "CATEGORY", - "safeName": "CATEGORY" + "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS_CHANNEL", + "safeName": "PETOWNERS_SUBSCRIBE_PARAMS_CHANNEL" }, "pascalCase": { - "unsafeName": "Category", - "safeName": "Category" + "unsafeName": "PetownersSubscribeParamsChannel", + "safeName": "PetownersSubscribeParamsChannel" } }, "fernFilepath": { - "allParts": [], - "packagePath": [] - } - }, - "properties": [ - { - "name": { - "wireValue": "id", - "name": { - "originalName": "id", + "allParts": [ + { + "originalName": "petowners", "camelCase": { - "unsafeName": "id", - "safeName": "id" + "unsafeName": "petowners", + "safeName": "petowners" }, "snakeCase": { - "unsafeName": "id", - "safeName": "id" + "unsafeName": "petowners", + "safeName": "petowners" }, "screamingSnakeCase": { - "unsafeName": "ID", - "safeName": "ID" + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" }, "pascalCase": { - "unsafeName": "ID", - "safeName": "ID" + "unsafeName": "Petowners", + "safeName": "Petowners" } } - }, - "typeReference": { - "value": { - "value": "LONG", - "type": "primitive" + ], + "packagePath": [], + "file": { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "name", - "name": { - "originalName": "name", - "camelCase": { - "unsafeName": "name", - "safeName": "name" - }, - "snakeCase": { - "unsafeName": "name", - "safeName": "name" - }, - "screamingSnakeCase": { - "unsafeName": "NAME", - "safeName": "NAME" - }, - "pascalCase": { - "unsafeName": "Name", - "safeName": "Name" - } - } - }, - "typeReference": { - "value": { - "value": "STRING", - "type": "primitive" + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" }, - "type": "optional" - } - } - ], - "additionalProperties": false, - "type": "object" - }, - "type_:Tag": { - "declaration": { - "name": { - "originalName": "Tag", - "camelCase": { - "unsafeName": "tag", - "safeName": "tag" - }, - "snakeCase": { - "unsafeName": "tag", - "safeName": "tag" - }, - "screamingSnakeCase": { - "unsafeName": "TAG", - "safeName": "TAG" - }, - "pascalCase": { - "unsafeName": "Tag", - "safeName": "Tag" + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } } - }, - "fernFilepath": { - "allParts": [], - "packagePath": [] } }, - "properties": [ + "values": [ { + "wireValue": "petowners", "name": { - "wireValue": "id", - "name": { - "originalName": "id", - "camelCase": { - "unsafeName": "id", - "safeName": "id" - }, - "snakeCase": { - "unsafeName": "id", - "safeName": "id" - }, - "screamingSnakeCase": { - "unsafeName": "ID", - "safeName": "ID" - }, - "pascalCase": { - "unsafeName": "ID", - "safeName": "ID" - } - } - }, - "typeReference": { - "value": { - "value": "LONG", - "type": "primitive" + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "name", - "name": { - "originalName": "name", - "camelCase": { - "unsafeName": "name", - "safeName": "name" - }, - "snakeCase": { - "unsafeName": "name", - "safeName": "name" - }, - "screamingSnakeCase": { - "unsafeName": "NAME", - "safeName": "NAME" - }, - "pascalCase": { - "unsafeName": "Name", - "safeName": "Name" - } - } - }, - "typeReference": { - "value": { - "value": "STRING", - "type": "primitive" + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" }, - "type": "optional" + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } } } ], - "additionalProperties": false, - "type": "object" + "type": "enum" }, - "type_:User": { + "type_petowners:PetownersSubscribeParamsData": { "declaration": { "name": { - "originalName": "User", + "originalName": "PetownersSubscribeParamsData", "camelCase": { - "unsafeName": "user", - "safeName": "user" + "unsafeName": "petownersSubscribeParamsData", + "safeName": "petownersSubscribeParamsData" }, "snakeCase": { - "unsafeName": "user", - "safeName": "user" + "unsafeName": "petowners_subscribe_params_data", + "safeName": "petowners_subscribe_params_data" }, "screamingSnakeCase": { - "unsafeName": "USER", - "safeName": "USER" + "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS_DATA", + "safeName": "PETOWNERS_SUBSCRIBE_PARAMS_DATA" }, "pascalCase": { - "unsafeName": "User", - "safeName": "User" + "unsafeName": "PetownersSubscribeParamsData", + "safeName": "PetownersSubscribeParamsData" } }, "fernFilepath": { - "allParts": [], - "packagePath": [] - } - }, - "properties": [ - { - "name": { - "wireValue": "id", - "name": { - "originalName": "id", + "allParts": [ + { + "originalName": "petowners", "camelCase": { - "unsafeName": "id", - "safeName": "id" + "unsafeName": "petowners", + "safeName": "petowners" }, "snakeCase": { - "unsafeName": "id", - "safeName": "id" + "unsafeName": "petowners", + "safeName": "petowners" }, "screamingSnakeCase": { - "unsafeName": "ID", - "safeName": "ID" + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" }, "pascalCase": { - "unsafeName": "ID", - "safeName": "ID" + "unsafeName": "Petowners", + "safeName": "Petowners" } } - }, - "typeReference": { - "value": { - "value": "LONG", - "type": "primitive" + ], + "packagePath": [], + "file": { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "username", - "name": { - "originalName": "username", - "camelCase": { - "unsafeName": "username", - "safeName": "username" - }, - "snakeCase": { - "unsafeName": "username", - "safeName": "username" - }, - "screamingSnakeCase": { - "unsafeName": "USERNAME", - "safeName": "USERNAME" - }, - "pascalCase": { - "unsafeName": "Username", - "safeName": "Username" - } - } - }, - "typeReference": { - "value": { - "value": "STRING", - "type": "primitive" + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" }, - "type": "optional" - } - }, - { - "name": { - "wireValue": "firstName", - "name": { - "originalName": "firstName", - "camelCase": { - "unsafeName": "firstName", - "safeName": "firstName" - }, - "snakeCase": { - "unsafeName": "first_name", - "safeName": "first_name" - }, - "screamingSnakeCase": { - "unsafeName": "FIRST_NAME", - "safeName": "FIRST_NAME" - }, - "pascalCase": { - "unsafeName": "FirstName", - "safeName": "FirstName" - } - } - }, - "typeReference": { - "value": { - "value": "STRING", - "type": "primitive" + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" }, - "type": "optional" + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } } - }, + } + }, + "properties": [ { "name": { - "wireValue": "lastName", + "wireValue": "petOwner", "name": { - "originalName": "lastName", + "originalName": "petOwner", "camelCase": { - "unsafeName": "lastName", - "safeName": "lastName" + "unsafeName": "petOwner", + "safeName": "petOwner" }, "snakeCase": { - "unsafeName": "last_name", - "safeName": "last_name" + "unsafeName": "pet_owner", + "safeName": "pet_owner" }, "screamingSnakeCase": { - "unsafeName": "LAST_NAME", - "safeName": "LAST_NAME" + "unsafeName": "PET_OWNER", + "safeName": "PET_OWNER" }, "pascalCase": { - "unsafeName": "LastName", - "safeName": "LastName" + "unsafeName": "PetOwner", + "safeName": "PetOwner" } } }, "typeReference": { - "value": { - "value": "STRING", - "type": "primitive" - }, - "type": "optional" + "value": "type_:PetOwner", + "type": "named" } - }, - { - "name": { - "wireValue": "email", - "name": { - "originalName": "email", - "camelCase": { - "unsafeName": "email", - "safeName": "email" - }, - "snakeCase": { - "unsafeName": "email", - "safeName": "email" - }, - "screamingSnakeCase": { - "unsafeName": "EMAIL", - "safeName": "EMAIL" - }, - "pascalCase": { - "unsafeName": "Email", - "safeName": "Email" - } - } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_petowners:PetownersSubscribeParams": { + "declaration": { + "name": { + "originalName": "PetownersSubscribeParams", + "camelCase": { + "unsafeName": "petownersSubscribeParams", + "safeName": "petownersSubscribeParams" }, - "typeReference": { - "value": { - "value": "STRING", - "type": "primitive" - }, - "type": "optional" + "snakeCase": { + "unsafeName": "petowners_subscribe_params", + "safeName": "petowners_subscribe_params" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS", + "safeName": "PETOWNERS_SUBSCRIBE_PARAMS" + }, + "pascalCase": { + "unsafeName": "PetownersSubscribeParams", + "safeName": "PetownersSubscribeParams" } }, - { - "name": { - "wireValue": "password", - "name": { - "originalName": "password", + "fernFilepath": { + "allParts": [ + { + "originalName": "petowners", "camelCase": { - "unsafeName": "password", - "safeName": "password" + "unsafeName": "petowners", + "safeName": "petowners" }, "snakeCase": { - "unsafeName": "password", - "safeName": "password" + "unsafeName": "petowners", + "safeName": "petowners" }, "screamingSnakeCase": { - "unsafeName": "PASSWORD", - "safeName": "PASSWORD" + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" }, "pascalCase": { - "unsafeName": "Password", - "safeName": "Password" + "unsafeName": "Petowners", + "safeName": "Petowners" } } - }, - "typeReference": { - "value": { - "value": "STRING", - "type": "primitive" + ], + "packagePath": [], + "file": { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" }, - "type": "optional" + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } } - }, + } + }, + "properties": [ { "name": { - "wireValue": "phone", + "wireValue": "channel", "name": { - "originalName": "phone", + "originalName": "channel", "camelCase": { - "unsafeName": "phone", - "safeName": "phone" + "unsafeName": "channel", + "safeName": "channel" }, "snakeCase": { - "unsafeName": "phone", - "safeName": "phone" + "unsafeName": "channel", + "safeName": "channel" }, "screamingSnakeCase": { - "unsafeName": "PHONE", - "safeName": "PHONE" + "unsafeName": "CHANNEL", + "safeName": "CHANNEL" }, "pascalCase": { - "unsafeName": "Phone", - "safeName": "Phone" + "unsafeName": "Channel", + "safeName": "Channel" } } }, "typeReference": { "value": { - "value": "STRING", - "type": "primitive" + "value": "type_petowners:PetownersSubscribeParamsChannel", + "type": "named" }, "type": "optional" } }, { "name": { - "wireValue": "userStatus", + "wireValue": "data", "name": { - "originalName": "userStatus", + "originalName": "data", "camelCase": { - "unsafeName": "userStatus", - "safeName": "userStatus" + "unsafeName": "data", + "safeName": "data" }, "snakeCase": { - "unsafeName": "user_status", - "safeName": "user_status" + "unsafeName": "data", + "safeName": "data" }, "screamingSnakeCase": { - "unsafeName": "USER_STATUS", - "safeName": "USER_STATUS" + "unsafeName": "DATA", + "safeName": "DATA" }, "pascalCase": { - "unsafeName": "UserStatus", - "safeName": "UserStatus" + "unsafeName": "Data", + "safeName": "Data" } } }, "typeReference": { "value": { - "value": "INTEGER", - "type": "primitive" + "value": "type_petowners:PetownersSubscribeParamsData", + "type": "named" }, "type": "optional" } @@ -2863,174 +654,142 @@ ], "additionalProperties": false, "type": "object" - } - }, - "headers": [], - "endpoints": { - "endpoint_pet.updateAnExistingPet": { + }, + "type_petowners:PetownersSubscribe": { "declaration": { "name": { - "originalName": "updateAnExistingPet", + "originalName": "PetownersSubscribe", "camelCase": { - "unsafeName": "updateAnExistingPet", - "safeName": "updateAnExistingPet" + "unsafeName": "petownersSubscribe", + "safeName": "petownersSubscribe" }, "snakeCase": { - "unsafeName": "update_an_existing_pet", - "safeName": "update_an_existing_pet" + "unsafeName": "petowners_subscribe", + "safeName": "petowners_subscribe" }, "screamingSnakeCase": { - "unsafeName": "UPDATE_AN_EXISTING_PET", - "safeName": "UPDATE_AN_EXISTING_PET" + "unsafeName": "PETOWNERS_SUBSCRIBE", + "safeName": "PETOWNERS_SUBSCRIBE" }, "pascalCase": { - "unsafeName": "UpdateAnExistingPet", - "safeName": "UpdateAnExistingPet" + "unsafeName": "PetownersSubscribe", + "safeName": "PetownersSubscribe" } }, "fernFilepath": { "allParts": [ { - "originalName": "pet", + "originalName": "petowners", "camelCase": { - "unsafeName": "pet", - "safeName": "pet" + "unsafeName": "petowners", + "safeName": "petowners" }, "snakeCase": { - "unsafeName": "pet", - "safeName": "pet" + "unsafeName": "petowners", + "safeName": "petowners" }, "screamingSnakeCase": { - "unsafeName": "PET", - "safeName": "PET" + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" }, "pascalCase": { - "unsafeName": "Pet", - "safeName": "Pet" + "unsafeName": "Petowners", + "safeName": "Petowners" } } ], "packagePath": [], "file": { - "originalName": "pet", + "originalName": "petowners", "camelCase": { - "unsafeName": "pet", - "safeName": "pet" + "unsafeName": "petowners", + "safeName": "petowners" }, "snakeCase": { - "unsafeName": "pet", - "safeName": "pet" + "unsafeName": "petowners", + "safeName": "petowners" }, "screamingSnakeCase": { - "unsafeName": "PET", - "safeName": "PET" + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" }, "pascalCase": { - "unsafeName": "Pet", - "safeName": "Pet" + "unsafeName": "Petowners", + "safeName": "Petowners" } } } }, - "location": { - "method": "PUT", - "path": "/pet" - }, - "request": { - "pathParameters": [], - "body": { - "value": { - "value": "type_:Pet", - "type": "named" + "properties": [ + { + "name": { + "wireValue": "params", + "name": { + "originalName": "params", + "camelCase": { + "unsafeName": "params", + "safeName": "params" + }, + "snakeCase": { + "unsafeName": "params", + "safeName": "params" + }, + "screamingSnakeCase": { + "unsafeName": "PARAMS", + "safeName": "PARAMS" + }, + "pascalCase": { + "unsafeName": "Params", + "safeName": "Params" + } + } }, - "type": "typeReference" - }, - "type": "body" - }, - "response": { - "type": "json" - }, - "examples": [] + "typeReference": { + "value": { + "value": "type_petowners:PetownersSubscribeParams", + "type": "named" + }, + "type": "optional" + } + } + ], + "additionalProperties": false, + "type": "object" }, - "endpoint_order.getAnOrder": { + "type_:PetOwner": { "declaration": { "name": { - "originalName": "getAnOrder", + "originalName": "PetOwner", "camelCase": { - "unsafeName": "getAnOrder", - "safeName": "getAnOrder" + "unsafeName": "petOwner", + "safeName": "petOwner" }, "snakeCase": { - "unsafeName": "get_an_order", - "safeName": "get_an_order" + "unsafeName": "pet_owner", + "safeName": "pet_owner" }, "screamingSnakeCase": { - "unsafeName": "GET_AN_ORDER", - "safeName": "GET_AN_ORDER" + "unsafeName": "PET_OWNER", + "safeName": "PET_OWNER" }, "pascalCase": { - "unsafeName": "GetAnOrder", - "safeName": "GetAnOrder" + "unsafeName": "PetOwner", + "safeName": "PetOwner" } }, "fernFilepath": { - "allParts": [ - { - "originalName": "order", - "camelCase": { - "unsafeName": "order", - "safeName": "order" - }, - "snakeCase": { - "unsafeName": "order", - "safeName": "order" - }, - "screamingSnakeCase": { - "unsafeName": "ORDER", - "safeName": "ORDER" - }, - "pascalCase": { - "unsafeName": "Order", - "safeName": "Order" - } - } - ], - "packagePath": [], - "file": { - "originalName": "order", - "camelCase": { - "unsafeName": "order", - "safeName": "order" - }, - "snakeCase": { - "unsafeName": "order", - "safeName": "order" - }, - "screamingSnakeCase": { - "unsafeName": "ORDER", - "safeName": "ORDER" - }, - "pascalCase": { - "unsafeName": "Order", - "safeName": "Order" - } - } + "allParts": [], + "packagePath": [] } }, - "location": { - "method": "GET", - "path": "/order" - }, - "request": { - "pathParameters": [], - "type": "body" - }, - "response": { - "type": "json" + "typeReference": { + "type": "unknown" }, - "examples": [] + "type": "alias" } }, + "headers": [], + "endpoints": {}, "pathParameters": [] }, "apiPlayground": true, @@ -3038,38 +797,6 @@ "smartCasing": true }, "subpackages": { - "subpackage_pet": { - "fernFilepath": { - "allParts": [ - "pet" - ], - "packagePath": [], - "file": "pet" - }, - "name": "pet", - "service": "service_pet", - "types": [], - "errors": [], - "subpackages": [], - "hasEndpointsInTree": true, - "hasWebSocketInTree": false - }, - "subpackage_order": { - "fernFilepath": { - "allParts": [ - "order" - ], - "packagePath": [], - "file": "order" - }, - "name": "order", - "service": "service_order", - "types": [], - "errors": [], - "subpackages": [], - "hasEndpointsInTree": true, - "hasWebSocketInTree": false - }, "subpackage_petowners": { "fernFilepath": { "allParts": [ @@ -3098,23 +825,13 @@ "packagePath": [] }, "types": [ - "type_:Mammal", - "type_:PetStatus", - "type_:Pet", - "type_:PetOwner", - "type_:OrderStatus", - "type_:Order", - "type_:Category", - "type_:Tag", - "type_:User" + "type_:PetOwner" ], "errors": [], "subpackages": [ - "subpackage_pet", - "subpackage_order", "subpackage_petowners" ], - "hasEndpointsInTree": true, + "hasEndpointsInTree": false, "hasWebSocketInTree": true }, "sdkConfig": { diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/allof-nested-ref.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/allof-nested-ref.json new file mode 100644 index 000000000000..c25ea7f0db98 --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/allof-nested-ref.json @@ -0,0 +1,3194 @@ +{ + "auth": { + "requirement": "ALL", + "schemes": [] + }, + "selfHosted": false, + "types": { + "UserStrict": { + "name": { + "typeId": "UserStrict", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserStrict" + }, + "shape": { + "properties": [ + { + "name": "firstName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserStrictFirstName_example_autogenerated": "string" + } + } + }, + { + "name": "lastName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserStrictLastName_example_autogenerated": "string" + } + } + }, + { + "name": "email", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "email" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserStrictEmail_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserStrict_example_autogenerated": { + "firstName": "string", + "lastName": "string", + "email": "string" + } + } + } + }, + "UserBase": { + "name": { + "typeId": "UserBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserBase" + }, + "shape": { + "properties": [ + { + "name": "firstName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserBaseFirstName_example_autogenerated": "string" + } + } + }, + { + "name": "lastName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserBaseLastName_example_autogenerated": "string" + } + } + }, + { + "name": "email", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "email" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserBaseEmail_example_autogenerated": "string" + } + } + }, + { + "name": "role", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "The user's role", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserBaseRole_example_autogenerated": "string" + } + } + }, + { + "name": "companyId", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "Associated company ID", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserBaseCompanyId_example_autogenerated": 1 + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserBase_example_autogenerated": { + "firstName": "string", + "lastName": "string", + "email": "string" + } + } + } + }, + "UserPost": { + "name": { + "typeId": "UserPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost" + }, + "shape": { + "properties": [ + { + "name": "firstName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPostFirstName_example_autogenerated": "string" + } + } + }, + { + "name": "lastName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPostLastName_example_autogenerated": "string" + } + } + }, + { + "name": "email", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "email" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPostEmail_example_autogenerated": "string" + } + } + }, + { + "name": "role", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "docs": "The user's role", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPostRole_example_autogenerated": "string" + } + } + }, + { + "name": "companyId", + "valueType": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "docs": "Associated company ID", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPostCompanyId_example_autogenerated": 1 + } + } + }, + { + "name": "acceptWelcomeEmail", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "Whether to send a welcome email", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPostAcceptWelcomeEmail_example_autogenerated": true + } + } + }, + { + "name": "originChannelId", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "Origin channel ID", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPostOriginChannelId_example_autogenerated": 1 + } + } + }, + { + "name": "channelIds", + "valueType": { + "container": { + "list": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "list" + }, + "type": "container" + }, + "docs": "List of channel IDs", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPostChannelIds_example_autogenerated": [ + 1 + ] + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPost_example_autogenerated": { + "firstName": "string", + "lastName": "string", + "email": "string", + "role": "string", + "companyId": 1, + "channelIds": [ + 1 + ] + } + } + } + }, + "AddressBase": { + "name": { + "typeId": "AddressBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AddressBase" + }, + "shape": { + "properties": [ + { + "name": "country", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AddressBaseCountry_example_autogenerated": "string" + } + } + }, + { + "name": "state", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AddressBaseState_example_autogenerated": "string" + } + } + }, + { + "name": "city", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AddressBaseCity_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "AddressBase_example_autogenerated": { + "country": "string" + } + } + } + }, + "CompanyBase": { + "name": { + "typeId": "CompanyBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyBase" + }, + "shape": { + "properties": [ + { + "name": "companyName", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CompanyBaseCompanyName_example_autogenerated": "string" + } + } + }, + { + "name": "companyPhone", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CompanyBaseCompanyPhone_example_autogenerated": "string" + } + } + } + ], + "extends": [ + { + "typeId": "AddressBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AddressBase", + "displayName": "AddressBase" + } + ], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CompanyBase_example_autogenerated": { + "country": "string" + } + } + } + }, + "CompanyPost": { + "name": { + "typeId": "CompanyPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyPost" + }, + "shape": { + "properties": [ + { + "name": "country", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CompanyPostCountry_example_autogenerated": "string" + } + } + }, + { + "name": "state", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CompanyPostState_example_autogenerated": "string" + } + } + }, + { + "name": "city", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CompanyPostCity_example_autogenerated": "string" + } + } + }, + { + "name": "companyName", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CompanyPostCompanyName_example_autogenerated": "string" + } + } + }, + { + "name": "companyPhone", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CompanyPostCompanyPhone_example_autogenerated": "string" + } + } + }, + { + "name": "taxId", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "Tax identification number", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CompanyPostTaxId_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CompanyPost_example_autogenerated": { + "country": "string", + "companyName": "string", + "companyPhone": "string" + } + } + } + }, + "Identifiable": { + "name": { + "typeId": "Identifiable", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Identifiable" + }, + "shape": { + "properties": [ + { + "name": "id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "uuid" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "IdentifiableId_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "Identifiable_example_autogenerated": { + "id": "string" + } + } + } + }, + "Describable": { + "name": { + "typeId": "Describable", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Describable" + }, + "shape": { + "properties": [ + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "DescribableName_example_autogenerated": "string" + } + } + }, + { + "name": "description", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "DescribableDescription_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "Describable_example_autogenerated": {} + } + } + }, + "PlantBaseWateringFrequency": { + "name": { + "typeId": "PlantBaseWateringFrequency", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantBaseWateringFrequency" + }, + "shape": { + "values": [ + { + "name": "daily" + }, + { + "name": "weekly" + }, + { + "name": "biweekly" + }, + { + "name": "monthly" + } + ], + "type": "enum" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantBaseWateringFrequency_example_autogenerated": "daily" + } + } + }, + "PlantBase": { + "name": { + "typeId": "PlantBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantBase" + }, + "shape": { + "properties": [ + { + "name": "id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "uuid" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantBaseId_example_autogenerated": "string" + } + } + }, + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantBaseName_example_autogenerated": "string" + } + } + }, + { + "name": "description", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantBaseDescription_example_autogenerated": "string" + } + } + }, + { + "name": "species", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantBaseSpecies_example_autogenerated": "string" + } + } + }, + { + "name": "wateringFrequency", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantBaseWateringFrequency", + "typeId": "PlantBaseWateringFrequency", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantBaseWateringFrequency_example_autogenerated": "daily" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantBase_example_autogenerated": { + "id": "string" + } + } + } + }, + "PlantRecordWateringFrequency": { + "name": { + "typeId": "PlantRecordWateringFrequency", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecordWateringFrequency" + }, + "shape": { + "values": [ + { + "name": "daily" + }, + { + "name": "weekly" + }, + { + "name": "biweekly" + }, + { + "name": "monthly" + } + ], + "type": "enum" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantRecordWateringFrequency_example_autogenerated": "daily" + } + } + }, + "PlantRecord": { + "name": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "shape": { + "properties": [ + { + "name": "id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": { + "format": "uuid" + }, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantRecordId_example_autogenerated": "string" + } + } + }, + { + "name": "name", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantRecordName_example_autogenerated": "string" + } + } + }, + { + "name": "description", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantRecordDescription_example_autogenerated": "string" + } + } + }, + { + "name": "species", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantRecordSpecies_example_autogenerated": "string" + } + } + }, + { + "name": "wateringFrequency", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecordWateringFrequency", + "typeId": "PlantRecordWateringFrequency", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantRecordWateringFrequency_example_autogenerated": "daily" + } + } + }, + { + "name": "plantedAt", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "DATE", + "v2": { + "type": "date" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "Date the plant was planted", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantRecordPlantedAt_example_autogenerated": "2023-01-15" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PlantRecord_example_autogenerated": { + "id": "string", + "name": "string", + "species": "string" + } + } + } + } + }, + "services": { + "service_": { + "name": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "basePath": { + "head": "", + "parts": [] + }, + "headers": [], + "pathParameters": [], + "endpoints": [ + { + "displayName": "Create a user (three-level allOf chain)", + "method": "POST", + "baseUrl": "https://api.example.com", + "path": { + "head": "/users", + "parts": [] + }, + "pathParameters": [], + "queryParameters": [], + "headers": [], + "responseHeaders": [], + "errors": [], + "auth": false, + "userSpecifiedExamples": [], + "autogeneratedExamples": [ + { + "example": { + "id": "335e3049", + "url": "/users", + "endpointHeaders": [], + "endpointPathParameters": [], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "request": { + "jsonExample": { + "firstName": "firstName", + "lastName": "lastName", + "email": "email", + "role": "role", + "companyId": 1, + "channelIds": [ + 1, + 1 + ] + }, + "shape": { + "shape": { + "properties": [ + { + "name": "firstName", + "originalTypeDeclaration": { + "typeId": "UserPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost" + }, + "value": { + "jsonExample": "firstName", + "shape": { + "primitive": { + "string": { + "original": "firstName" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "lastName", + "originalTypeDeclaration": { + "typeId": "UserPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost" + }, + "value": { + "jsonExample": "lastName", + "shape": { + "primitive": { + "string": { + "original": "lastName" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "email", + "originalTypeDeclaration": { + "typeId": "UserPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost" + }, + "value": { + "jsonExample": "email", + "shape": { + "primitive": { + "string": { + "original": "email" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "role", + "originalTypeDeclaration": { + "typeId": "UserPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost" + }, + "value": { + "jsonExample": "role", + "shape": { + "primitive": { + "string": { + "original": "role" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "companyId", + "originalTypeDeclaration": { + "typeId": "UserPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost" + }, + "value": { + "jsonExample": 1, + "shape": { + "primitive": { + "integer": 1, + "type": "integer" + }, + "type": "primitive" + } + } + }, + { + "name": "acceptWelcomeEmail", + "originalTypeDeclaration": { + "typeId": "UserPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "originChannelId", + "originalTypeDeclaration": { + "typeId": "UserPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "channelIds", + "originalTypeDeclaration": { + "typeId": "UserPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost" + }, + "value": { + "jsonExample": [ + 1, + 1 + ], + "shape": { + "container": { + "list": [ + { + "jsonExample": 1, + "shape": { + "primitive": { + "integer": 1, + "type": "integer" + }, + "type": "primitive" + } + }, + { + "jsonExample": 1, + "shape": { + "primitive": { + "integer": 1, + "type": "integer" + }, + "type": "primitive" + } + } + ], + "itemType": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "list" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "UserPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost" + }, + "type": "named" + }, + "type": "reference" + }, + "response": { + "value": { + "value": { + "jsonExample": { + "firstName": "firstName", + "lastName": "lastName", + "email": "email" + }, + "shape": { + "shape": { + "properties": [ + { + "name": "firstName", + "originalTypeDeclaration": { + "typeId": "UserStrict", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserStrict" + }, + "value": { + "jsonExample": "firstName", + "shape": { + "primitive": { + "string": { + "original": "firstName" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "lastName", + "originalTypeDeclaration": { + "typeId": "UserStrict", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserStrict" + }, + "value": { + "jsonExample": "lastName", + "shape": { + "primitive": { + "string": { + "original": "lastName" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "email", + "originalTypeDeclaration": { + "typeId": "UserStrict", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserStrict" + }, + "value": { + "jsonExample": "email", + "shape": { + "primitive": { + "string": { + "original": "email" + }, + "type": "string" + }, + "type": "primitive" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "UserStrict", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserStrict" + }, + "type": "named" + } + }, + "type": "body" + }, + "type": "ok" + } + } + } + ], + "idempotent": false, + "fullPath": { + "head": "/users", + "parts": [] + }, + "allPathParameters": [], + "source": { + "type": "openapi" + }, + "audiences": [], + "id": "endpoint_.createUser", + "name": "createUser", + "requestBody": { + "contentType": "application/json", + "requestBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost", + "typeId": "UserPost", + "inline": false, + "displayName": "UserPost", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createUserExample": { + "channelIds": [ + 1 + ], + "firstName": "string", + "lastName": "string", + "email": "string" + } + } + }, + "type": "reference" + }, + "v2RequestBodies": { + "requestBodies": [ + { + "contentType": "application/json", + "requestBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserPost", + "typeId": "UserPost", + "inline": false, + "displayName": "UserPost", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createUserExample": { + "channelIds": [ + 1 + ], + "firstName": "string", + "lastName": "string", + "email": "string" + } + } + }, + "type": "reference" + } + ] + }, + "response": { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserStrict", + "typeId": "UserStrict", + "inline": false, + "displayName": "UserStrict", + "type": "named" + }, + "docs": "Created user", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createUserExample": { + "firstName": "string", + "lastName": "string", + "email": "string" + } + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "Created user" + }, + "docs": "Tests the core BigCommerce pattern: userPost -> userBase -> userStrict. The grandparent's properties must be resolved through the nested $ref.\n", + "v2Examples": { + "autogeneratedExamples": { + "createUserExample_200": { + "displayName": "createUserExample", + "request": { + "endpoint": { + "method": "POST", + "path": "/users" + }, + "environment": "https://api.example.com", + "pathParameters": {}, + "queryParameters": {}, + "headers": {}, + "requestBody": { + "channelIds": [ + 1 + ], + "firstName": "string", + "lastName": "string", + "email": "string" + } + }, + "response": { + "statusCode": 200, + "body": { + "value": { + "firstName": "string", + "lastName": "string", + "email": "string" + }, + "type": "json" + } + } + } + }, + "userSpecifiedExamples": {} + }, + "v2Responses": { + "responses": [ + { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "UserStrict", + "typeId": "UserStrict", + "inline": false, + "displayName": "UserStrict", + "type": "named" + }, + "docs": "Created user", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createUserExample": { + "firstName": "string", + "lastName": "string", + "email": "string" + } + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "Created user" + } + ] + } + }, + { + "displayName": "Create a company (sibling properties alongside nested $ref)", + "method": "POST", + "baseUrl": "https://api.example.com", + "path": { + "head": "/companies", + "parts": [] + }, + "pathParameters": [], + "queryParameters": [], + "headers": [], + "responseHeaders": [], + "errors": [], + "auth": false, + "userSpecifiedExamples": [], + "autogeneratedExamples": [ + { + "example": { + "id": "920b43b0", + "url": "/companies", + "endpointHeaders": [], + "endpointPathParameters": [], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "request": { + "jsonExample": { + "country": "country", + "companyName": "companyName", + "companyPhone": "companyPhone" + }, + "shape": { + "shape": { + "properties": [ + { + "name": "country", + "originalTypeDeclaration": { + "typeId": "CompanyPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyPost" + }, + "value": { + "jsonExample": "country", + "shape": { + "primitive": { + "string": { + "original": "country" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "state", + "originalTypeDeclaration": { + "typeId": "CompanyPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyPost" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "city", + "originalTypeDeclaration": { + "typeId": "CompanyPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyPost" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "companyName", + "originalTypeDeclaration": { + "typeId": "CompanyPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyPost" + }, + "value": { + "jsonExample": "companyName", + "shape": { + "primitive": { + "string": { + "original": "companyName" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "companyPhone", + "originalTypeDeclaration": { + "typeId": "CompanyPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyPost" + }, + "value": { + "jsonExample": "companyPhone", + "shape": { + "primitive": { + "string": { + "original": "companyPhone" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "taxId", + "originalTypeDeclaration": { + "typeId": "CompanyPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyPost" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "CompanyPost", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyPost" + }, + "type": "named" + }, + "type": "reference" + }, + "response": { + "value": { + "value": { + "jsonExample": { + "country": "country", + "state": "state", + "city": "city", + "companyName": "companyName", + "companyPhone": "companyPhone" + }, + "shape": { + "shape": { + "properties": [ + { + "name": "country", + "originalTypeDeclaration": { + "typeId": "AddressBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AddressBase" + }, + "value": { + "jsonExample": "country", + "shape": { + "primitive": { + "string": { + "original": "country" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "state", + "originalTypeDeclaration": { + "typeId": "AddressBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AddressBase" + }, + "value": { + "jsonExample": "state", + "shape": { + "container": { + "optional": { + "jsonExample": "state", + "shape": { + "primitive": { + "string": { + "original": "state" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "city", + "originalTypeDeclaration": { + "typeId": "AddressBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "AddressBase" + }, + "value": { + "jsonExample": "city", + "shape": { + "container": { + "optional": { + "jsonExample": "city", + "shape": { + "primitive": { + "string": { + "original": "city" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "companyName", + "originalTypeDeclaration": { + "typeId": "CompanyBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyBase" + }, + "value": { + "jsonExample": "companyName", + "shape": { + "container": { + "optional": { + "jsonExample": "companyName", + "shape": { + "primitive": { + "string": { + "original": "companyName" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "companyPhone", + "originalTypeDeclaration": { + "typeId": "CompanyBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyBase" + }, + "value": { + "jsonExample": "companyPhone", + "shape": { + "container": { + "optional": { + "jsonExample": "companyPhone", + "shape": { + "primitive": { + "string": { + "original": "companyPhone" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "CompanyBase", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyBase" + }, + "type": "named" + } + }, + "type": "body" + }, + "type": "ok" + } + } + } + ], + "idempotent": false, + "fullPath": { + "head": "/companies", + "parts": [] + }, + "allPathParameters": [], + "source": { + "type": "openapi" + }, + "audiences": [], + "id": "endpoint_.createCompany", + "name": "createCompany", + "requestBody": { + "contentType": "application/json", + "requestBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyPost", + "typeId": "CompanyPost", + "inline": false, + "displayName": "CompanyPost", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createCompanyExample": { + "companyName": "string", + "companyPhone": "string", + "country": "string" + } + } + }, + "type": "reference" + }, + "v2RequestBodies": { + "requestBodies": [ + { + "contentType": "application/json", + "requestBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyPost", + "typeId": "CompanyPost", + "inline": false, + "displayName": "CompanyPost", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createCompanyExample": { + "companyName": "string", + "companyPhone": "string", + "country": "string" + } + } + }, + "type": "reference" + } + ] + }, + "response": { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyBase", + "typeId": "CompanyBase", + "inline": false, + "displayName": "CompanyBase", + "type": "named" + }, + "docs": "Created company", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createCompanyExample": { + "companyName": "string", + "companyPhone": "string", + "country": "string", + "state": "string", + "city": "string" + } + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "Created company" + }, + "docs": "Tests that sibling properties at the parent level (companyBase has its own properties in addition to $ref) are all preserved.\n", + "v2Examples": { + "autogeneratedExamples": { + "createCompanyExample_200": { + "displayName": "createCompanyExample", + "request": { + "endpoint": { + "method": "POST", + "path": "/companies" + }, + "environment": "https://api.example.com", + "pathParameters": {}, + "queryParameters": {}, + "headers": {}, + "requestBody": { + "companyName": "string", + "companyPhone": "string", + "country": "string" + } + }, + "response": { + "statusCode": 200, + "body": { + "value": { + "companyName": "string", + "companyPhone": "string", + "country": "string", + "state": "string", + "city": "string" + }, + "type": "json" + } + } + } + }, + "userSpecifiedExamples": {} + }, + "v2Responses": { + "responses": [ + { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "CompanyBase", + "typeId": "CompanyBase", + "inline": false, + "displayName": "CompanyBase", + "type": "named" + }, + "docs": "Created company", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createCompanyExample": { + "companyName": "string", + "companyPhone": "string", + "country": "string", + "state": "string", + "city": "string" + } + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "Created company" + } + ] + } + }, + { + "displayName": "Create a plant (multiple $ref in single allOf)", + "method": "POST", + "baseUrl": "https://api.example.com", + "path": { + "head": "/plants", + "parts": [] + }, + "pathParameters": [], + "queryParameters": [], + "headers": [], + "responseHeaders": [], + "errors": [], + "auth": false, + "userSpecifiedExamples": [], + "autogeneratedExamples": [ + { + "example": { + "id": "45058f3c", + "url": "/plants", + "endpointHeaders": [], + "endpointPathParameters": [], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "request": { + "jsonExample": { + "id": "id", + "name": "name", + "species": "species" + }, + "shape": { + "shape": { + "properties": [ + { + "name": "id", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "jsonExample": "id", + "shape": { + "primitive": { + "string": { + "original": "id" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "name", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "jsonExample": "name", + "shape": { + "primitive": { + "string": { + "original": "name" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "description", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "species", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "jsonExample": "species", + "shape": { + "primitive": { + "string": { + "original": "species" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "wateringFrequency", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "shape": { + "container": { + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecordWateringFrequency", + "typeId": "PlantRecordWateringFrequency", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "plantedAt", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "DATE", + "v2": { + "type": "date" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "type": "named" + }, + "type": "reference" + }, + "response": { + "value": { + "value": { + "jsonExample": { + "id": "id", + "name": "name", + "description": "description", + "species": "species", + "wateringFrequency": "daily", + "plantedAt": "2023-01-15" + }, + "shape": { + "shape": { + "properties": [ + { + "name": "id", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "jsonExample": "id", + "shape": { + "primitive": { + "string": { + "original": "id" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "name", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "jsonExample": "name", + "shape": { + "primitive": { + "string": { + "original": "name" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "description", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "jsonExample": "description", + "shape": { + "container": { + "optional": { + "jsonExample": "description", + "shape": { + "primitive": { + "string": { + "original": "description" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "species", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "jsonExample": "species", + "shape": { + "primitive": { + "string": { + "original": "species" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "wateringFrequency", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "jsonExample": "daily", + "shape": { + "container": { + "optional": { + "jsonExample": "daily", + "shape": { + "shape": { + "value": "daily", + "type": "enum" + }, + "typeName": { + "typeId": "PlantRecordWateringFrequency", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecordWateringFrequency" + }, + "type": "named" + } + }, + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecordWateringFrequency", + "typeId": "PlantRecordWateringFrequency", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "plantedAt", + "originalTypeDeclaration": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "value": { + "jsonExample": "2023-01-15", + "shape": { + "container": { + "optional": { + "jsonExample": "2023-01-15", + "shape": { + "primitive": { + "date": "2023-01-15", + "type": "date" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "DATE", + "v2": { + "type": "date" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "PlantRecord", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord" + }, + "type": "named" + } + }, + "type": "body" + }, + "type": "ok" + } + } + } + ], + "idempotent": false, + "fullPath": { + "head": "/plants", + "parts": [] + }, + "allPathParameters": [], + "source": { + "type": "openapi" + }, + "audiences": [], + "id": "endpoint_.createPlant", + "name": "createPlant", + "requestBody": { + "contentType": "application/json", + "requestBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord", + "typeId": "PlantRecord", + "inline": false, + "displayName": "PlantRecord", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createPlantExample": { + "id": "string" + } + } + }, + "type": "reference" + }, + "v2RequestBodies": { + "requestBodies": [ + { + "contentType": "application/json", + "requestBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord", + "typeId": "PlantRecord", + "inline": false, + "displayName": "PlantRecord", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createPlantExample": { + "id": "string" + } + } + }, + "type": "reference" + } + ] + }, + "response": { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord", + "typeId": "PlantRecord", + "inline": false, + "displayName": "PlantRecord", + "type": "named" + }, + "docs": "Created plant", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createPlantExample": { + "plantedAt": "2023-01-15", + "id": "string", + "name": "string", + "description": "string", + "species": "string", + "wateringFrequency": "daily" + } + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "Created plant" + }, + "docs": "Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged.\n", + "v2Examples": { + "autogeneratedExamples": { + "createPlantExample_200": { + "displayName": "createPlantExample", + "request": { + "endpoint": { + "method": "POST", + "path": "/plants" + }, + "environment": "https://api.example.com", + "pathParameters": {}, + "queryParameters": {}, + "headers": {}, + "requestBody": { + "id": "string" + } + }, + "response": { + "statusCode": 200, + "body": { + "value": { + "plantedAt": "2023-01-15", + "id": "string", + "name": "string", + "description": "string", + "species": "string", + "wateringFrequency": "daily" + }, + "type": "json" + } + } + } + }, + "userSpecifiedExamples": {} + }, + "v2Responses": { + "responses": [ + { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PlantRecord", + "typeId": "PlantRecord", + "inline": false, + "displayName": "PlantRecord", + "type": "named" + }, + "docs": "Created plant", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "createPlantExample": { + "plantedAt": "2023-01-15", + "id": "string", + "name": "string", + "description": "string", + "species": "string", + "wateringFrequency": "daily" + } + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "Created plant" + } + ] + } + } + ] + } + }, + "errors": {}, + "webhookGroups": {}, + "headers": [], + "idempotencyHeaders": [], + "apiDisplayName": "Nested allOf $ref Resolution", + "pathParameters": [], + "errorDiscriminationStrategy": { + "type": "statusCode" + }, + "variables": [], + "serviceTypeReferenceInfo": { + "sharedTypes": [], + "typesReferencedOnlyByService": {} + }, + "environments": { + "defaultEnvironment": "https://api.example.com", + "environments": { + "environments": [ + { + "id": "https://api.example.com", + "name": "https://api.example.com", + "url": "https://api.example.com" + } + ], + "type": "singleBaseUrl" + } + }, + "rootPackage": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "service": "service_", + "types": [ + "UserStrict", + "UserBase", + "UserPost", + "AddressBase", + "CompanyBase", + "CompanyPost", + "Identifiable", + "Describable", + "PlantBase", + "PlantRecord" + ], + "errors": [], + "subpackages": [], + "hasEndpointsInTree": false + }, + "subpackages": {}, + "sdkConfig": { + "hasFileDownloadEndpoints": false, + "hasPaginatedEndpoints": false, + "hasStreamingEndpoints": false, + "isAuthMandatory": true, + "platformHeaders": { + "language": "", + "sdkName": "", + "sdkVersion": "" + } + }, + "apiName": "Nested allOf $ref Resolution", + "constants": { + "errorInstanceIdKey": "errorInstanceId" + } +} \ No newline at end of file diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/url-reference.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/url-reference.json index b5d325b4f34e..f2941416e284 100644 --- a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/url-reference.json +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/url-reference.json @@ -36,36 +36,7 @@ "name": "Mammal" }, "shape": { - "members": [ - { - "type": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet", - "typeId": "Pet", - "inline": false, - "displayName": "a Pet", - "type": "named" - }, - "docs": "A pet for sale in the pet store" - }, - { - "type": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "User", - "typeId": "User", - "inline": false, - "displayName": "a User", - "type": "named" - }, - "docs": "A User who is purchasing from the pet store" - } - ], + "members": [], "type": "undiscriminatedUnion" }, "autogeneratedExamples": [], @@ -75,64 +46,7 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "Mammal_example_autogenerated": { - "id": 0, - "category": { - "id": 6, - "name": "name" - }, - "name": "doggie", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1, - "name": "name" - }, - { - "id": 1, - "name": "name" - } - ], - "status": "available" - } - } - } - }, - "PetStatus": { - "name": { - "typeId": "PetStatus", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetStatus" - }, - "shape": { - "values": [ - { - "name": "available" - }, - { - "name": "pending" - }, - { - "name": "sold" - } - ], - "type": "enum" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "docs": "pet status in the store", - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetStatus_example_autogenerated": "available" + "Mammal_example_autogenerated": null } } }, @@ -146,239 +60,22 @@ "name": "Pet" }, "shape": { - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetId_example_autogenerated": 1 - } - } - }, - { - "name": "category", - "valueType": { - "container": { - "optional": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Category", - "typeId": "Category", - "inline": false, - "displayName": "Pet category", - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": { - "PetCategory_example_0": { - "id": 6, - "name": "name" - } - }, - "autogeneratedExamples": {} - } - }, - { - "name": "name", - "valueType": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "v2Examples": { - "userSpecifiedExamples": { - "PetName_example_0": "doggie" - }, - "autogeneratedExamples": {} - } - }, - { - "name": "photoUrls", - "valueType": { - "container": { - "list": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "list" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetPhotoUrls_example_autogenerated": [ - "string" - ] - } - } - }, - { - "name": "tags", - "valueType": { - "container": { - "optional": { - "container": { - "list": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag", - "typeId": "Tag", - "inline": false, - "displayName": "Pet Tag", - "type": "named" - }, - "type": "list" - }, - "type": "container" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetTags_example_autogenerated": [ - {} - ] - } - } - }, - { - "name": "status", - "valueType": { - "container": { - "optional": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetStatus", - "typeId": "PetStatus", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "docs": "pet status in the store", - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetStatus_example_autogenerated": "available" - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "docs": "A pet for sale in the pet store", - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": { - "Pet_example_0": { - "id": 0, - "category": { - "id": 6, - "name": "name" - }, - "name": "doggie", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1, - "name": "name" - }, - { - "id": 1, - "name": "name" - } - ], - "status": "available" - } + "aliasOf": { + "type": "unknown" }, - "autogeneratedExamples": {} - } - }, - "PetOwnerStatus": { - "name": { - "typeId": "PetOwnerStatus", - "fernFilepath": { - "allParts": [], - "packagePath": [] + "resolvedType": { + "type": "unknown" }, - "name": "PetOwnerStatus" - }, - "shape": { - "values": [ - { - "name": "available" - }, - { - "name": "pending" - }, - { - "name": "sold" - } - ], - "type": "enum" + "type": "alias" }, "autogeneratedExamples": [], "userProvidedExamples": [], - "docs": "pet status in the store", "referencedTypes": {}, "inline": false, "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "PetOwnerStatus_example_autogenerated": "available" + "Pet_example_autogenerated": null } } }, @@ -389,1588 +86,10 @@ "allParts": [], "packagePath": [] }, - "name": "PetOwner" - }, - "shape": { - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerId_example_autogenerated": 1 - } - } - }, - { - "name": "username", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerUsername_example_autogenerated": "string" - } - } - }, - { - "name": "firstName", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerFirstName_example_autogenerated": "string" - } - } - }, - { - "name": "lastName", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerLastName_example_autogenerated": "string" - } - } - }, - { - "name": "email", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerEmail_example_autogenerated": "string" - } - } - }, - { - "name": "password", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerPassword_example_autogenerated": "string" - } - } - }, - { - "name": "phone", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerPhone_example_autogenerated": "string" - } - } - }, - { - "name": "userStatus", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "INTEGER", - "v2": { - "validation": {}, - "type": "integer" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "docs": "User Status", - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerUserStatus_example_autogenerated": 1 - } - } - }, - { - "name": "category", - "valueType": { - "container": { - "optional": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetOwnerCategory", - "typeId": "PetOwnerCategory", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "docs": "A category for a pet", - "v2Examples": { - "userSpecifiedExamples": { - "PetOwnerCategory_example_0": { - "id": 6, - "name": "name" - } - }, - "autogeneratedExamples": {} - } - }, - { - "name": "name", - "valueType": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "v2Examples": { - "userSpecifiedExamples": { - "PetOwnerName_example_0": "doggie" - }, - "autogeneratedExamples": {} - } - }, - { - "name": "photoUrls", - "valueType": { - "container": { - "list": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "list" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerPhotoUrls_example_autogenerated": [ - "string" - ] - } - } - }, - { - "name": "tags", - "valueType": { - "container": { - "optional": { - "container": { - "list": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetOwnerTagsItems", - "typeId": "PetOwnerTagsItems", - "inline": false, - "type": "named" - }, - "type": "list" - }, - "type": "container" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerTags_example_autogenerated": [ - {} - ] - } - } - }, - { - "name": "status", - "valueType": { - "container": { - "optional": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetOwnerStatus", - "typeId": "PetOwnerStatus", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "docs": "pet status in the store", - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerStatus_example_autogenerated": "available" - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "docs": "A tag for a pet", - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": { - "PetOwner_example_0": { - "id": 1, - "username": "username", - "firstName": "firstName", - "lastName": "lastName", - "email": "email", - "password": "password", - "phone": "phone", - "userStatus": 6, - "category": { - "id": 6, - "name": "name" - }, - "name": "name", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1, - "name": "name" - }, - { - "id": 1, - "name": "name" - } - ], - "status": "available" - } - }, - "autogeneratedExamples": {} - } - }, - "OrderStatus": { - "name": { - "typeId": "OrderStatus", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "OrderStatus" - }, - "shape": { - "values": [ - { - "name": "placed" - }, - { - "name": "approved" - }, - { - "name": "delivered" - } - ], - "type": "enum" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "docs": "Order Status", - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "OrderStatus_example_autogenerated": "placed" - } - } - }, - "Order": { - "name": { - "typeId": "Order", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Order" - }, - "shape": { - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "OrderId_example_autogenerated": 1 - } - } - }, - { - "name": "petId", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "OrderPetId_example_autogenerated": 1 - } - } - }, - { - "name": "quantity", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "INTEGER", - "v2": { - "validation": {}, - "type": "integer" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "OrderQuantity_example_autogenerated": 1 - } - } - }, - { - "name": "shipDate", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "DATE_TIME", - "v2": { - "type": "dateTime" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "OrderShipDate_example_autogenerated": "2024-01-15T09:30:00Z" - } - } - }, - { - "name": "status", - "valueType": { - "container": { - "optional": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "OrderStatus", - "typeId": "OrderStatus", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "docs": "Order Status", - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "OrderStatus_example_autogenerated": "placed" - } - } - }, - { - "name": "complete", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "BOOLEAN", - "v2": { - "default": false, - "type": "boolean" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "defaultValue": false, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "OrderComplete_example_autogenerated": false - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "docs": "An order for a pets from the pet store", - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": { - "Order_example_0": { - "id": 0, - "petId": 6, - "quantity": 1, - "shipDate": "2000-01-23T04:56:07.000+00:00", - "status": "placed", - "complete": false - } - }, - "autogeneratedExamples": {} - } - }, - "Category": { - "name": { - "typeId": "Category", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Category" - }, - "shape": { - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "CategoryId_example_autogenerated": 1 - } - } - }, - { - "name": "name", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "CategoryName_example_autogenerated": "string" - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "docs": "A category for a pet", - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": { - "Category_example_0": { - "id": 6, - "name": "name" - } - }, - "autogeneratedExamples": {} - } - }, - "Tag": { - "name": { - "typeId": "Tag", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag" - }, - "shape": { - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "TagId_example_autogenerated": 1 - } - } - }, - { - "name": "name", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "TagName_example_autogenerated": "string" - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "docs": "A tag for a pet", - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": { - "Tag_example_0": { - "id": 1, - "name": "name" - } - }, - "autogeneratedExamples": {} - } - }, - "User": { - "name": { - "typeId": "User", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "User" - }, - "shape": { - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "UserId_example_autogenerated": 1 - } - } - }, - { - "name": "username", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "UserUsername_example_autogenerated": "string" - } - } - }, - { - "name": "firstName", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "UserFirstName_example_autogenerated": "string" - } - } - }, - { - "name": "lastName", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "UserLastName_example_autogenerated": "string" - } - } - }, - { - "name": "email", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "UserEmail_example_autogenerated": "string" - } - } - }, - { - "name": "password", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "UserPassword_example_autogenerated": "string" - } - } - }, - { - "name": "phone", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "UserPhone_example_autogenerated": "string" - } - } - }, - { - "name": "userStatus", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "INTEGER", - "v2": { - "validation": {}, - "type": "integer" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "docs": "User Status", - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "UserUserStatus_example_autogenerated": 1 - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "docs": "A User who is purchasing from the pet store", - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": { - "User_example_0": { - "id": 0, - "username": "username", - "firstName": "firstName", - "lastName": "lastName", - "email": "email", - "password": "password", - "phone": "phone", - "userStatus": 6 - } - }, - "autogeneratedExamples": {} - } - }, - "PetOwnerCategory": { - "name": { - "typeId": "PetOwnerCategory", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetOwnerCategory" - }, - "shape": { - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerCategoryId_example_autogenerated": 1 - } - } - }, - { - "name": "name", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerCategoryName_example_autogenerated": "string" - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "docs": "A category for a pet", - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": { - "PetOwnerCategory_example_0": { - "id": 6, - "name": "name" - } - }, - "autogeneratedExamples": {} - } - }, - "PetOwnerTagsItems": { - "name": { - "typeId": "PetOwnerTagsItems", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetOwnerTagsItems" - }, - "shape": { - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerTagsItemsId_example_autogenerated": 1 - } - } - }, - { - "name": "name", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetOwnerTagsItemsName_example_autogenerated": "string" - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "docs": "A tag for a pet", - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": { - "PetOwnerTagsItems_example_0": { - "id": 1, - "name": "name" - } - }, - "autogeneratedExamples": {} - } - }, - "ChannelsPetownersSubscribeParamsData": { - "name": { - "typeId": "ChannelsPetownersSubscribeParamsData", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "ChannelsPetownersSubscribeParamsData" - }, - "shape": { - "properties": [ - { - "name": "petOwner", - "valueType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetOwner", - "typeId": "PetOwner", - "displayName": "PetOwner", - "inline": false, - "type": "named" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsPetownersSubscribeParamsDataPetOwner_example_autogenerated": { - "id": 1, - "username": "string", - "firstName": "string", - "lastName": "string", - "email": "string", - "password": "string", - "phone": "string", - "userStatus": 1, - "category": { - "id": 6, - "name": "name" - }, - "name": "doggie", - "photoUrls": [ - "string" - ], - "tags": [ - { - "id": 1, - "name": "string" - } - ], - "status": "available" - } - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsPetownersSubscribeParamsData_example_autogenerated": { - "petOwner": { - "name": "doggie", - "photoUrls": [ - "string" - ] - } - } - } - } - }, - "ChannelsPetownersSubscribeParams": { - "name": { - "typeId": "ChannelsPetownersSubscribeParams", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "ChannelsPetownersSubscribeParams" - }, - "shape": { - "properties": [ - { - "name": "channel", - "valueType": { - "container": { - "optional": { - "container": { - "literal": { - "string": "petowners", - "type": "string" - }, - "type": "literal" - }, - "type": "container" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsPetownersSubscribeParamsChannel_example_autogenerated": "string" - } - } - }, - { - "name": "data", - "valueType": { - "container": { - "optional": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "ChannelsPetownersSubscribeParamsData", - "typeId": "ChannelsPetownersSubscribeParamsData", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsPetownersSubscribeParamsData_example_autogenerated": { - "petOwner": { - "name": "doggie", - "photoUrls": [ - "string" - ] - } - } - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsPetownersSubscribeParams_example_autogenerated": {} - } - } - }, - "PetownersSubscribe": { - "name": { - "typeId": "PetownersSubscribe", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetownersSubscribe" - }, - "shape": { - "properties": [ - { - "name": "params", - "valueType": { - "container": { - "optional": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "ChannelsPetownersSubscribeParams", - "typeId": "ChannelsPetownersSubscribeParams", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsPetownersSubscribeParams_example_autogenerated": {} - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "PetownersSubscribe_example_autogenerated": {} - } - } - }, - "ChannelsTestChannelMessagesSendMessageDataData": { - "name": { - "typeId": "ChannelsTestChannelMessagesSendMessageDataData", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "ChannelsTestChannelMessagesSendMessageDataData" - }, - "shape": { - "properties": [ - { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessageDataDataId_example_autogenerated": "string" - } - } - }, - { - "name": "name", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessageDataDataName_example_autogenerated": "string" - } - } - }, - { - "name": "age", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "INTEGER", - "v2": { - "validation": {}, - "type": "integer" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessageDataDataAge_example_autogenerated": 1 - } - } - }, - { - "name": "address", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessageDataDataAddress_example_autogenerated": "string" - } - } - } - ], - "extends": [], - "extendedProperties": [], - "extraProperties": false, - "type": "object" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessageDataData_example_autogenerated": {} - } - } - }, - "ChannelsTestChannelMessagesSendMessageData": { - "name": { - "typeId": "ChannelsTestChannelMessagesSendMessageData", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "ChannelsTestChannelMessagesSendMessageData" - }, - "shape": { - "properties": [ - { - "name": "message", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessageDataMessage_example_autogenerated": "string" - } - } - }, - { - "name": "data", - "valueType": { - "container": { - "optional": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "ChannelsTestChannelMessagesSendMessageDataData", - "typeId": "ChannelsTestChannelMessagesSendMessageDataData", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessageDataData_example_autogenerated": {} - } - } - } - ], + "name": "PetOwner" + }, + "shape": { + "properties": [], "extends": [], "extendedProperties": [], "extraProperties": false, @@ -1983,68 +102,67 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessageData_example_autogenerated": {} + "PetOwner_example_autogenerated": null + } + } + }, + "Order": { + "name": { + "typeId": "Order", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Order" + }, + "shape": { + "aliasOf": { + "type": "unknown" + }, + "resolvedType": { + "type": "unknown" + }, + "type": "alias" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "Order_example_autogenerated": null } } }, - "testChannel_sendMessage": { + "ChannelsPetownersSubscribeParamsData": { "name": { - "typeId": "testChannel_sendMessage", + "typeId": "ChannelsPetownersSubscribeParamsData", "fernFilepath": { "allParts": [], "packagePath": [] }, - "name": "testChannel_sendMessage" + "name": "ChannelsPetownersSubscribeParamsData" }, "shape": { "properties": [ { - "name": "message", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessageMessage_example_autogenerated": "string" - } - } - }, - { - "name": "data", + "name": "petOwner", "valueType": { - "container": { - "optional": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "ChannelsTestChannelMessagesSendMessageData", - "typeId": "ChannelsTestChannelMessagesSendMessageData", - "inline": false, - "type": "named" - }, - "type": "optional" + "fernFilepath": { + "allParts": [], + "packagePath": [] }, - "type": "container" + "name": "PetOwner", + "typeId": "PetOwner", + "displayName": "PetOwner", + "inline": false, + "type": "named" }, "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessageData_example_autogenerated": {} + "ChannelsPetownersSubscribeParamsDataPetOwner_example_autogenerated": null } } } @@ -2061,84 +179,36 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "testChannel_sendMessage_example_autogenerated": {} + "ChannelsPetownersSubscribeParamsData_example_autogenerated": { + "petOwner": null + } } } }, - "ChannelsTestChannelMessagesSendMessage2Data": { + "ChannelsPetownersSubscribeParams": { "name": { - "typeId": "ChannelsTestChannelMessagesSendMessage2Data", + "typeId": "ChannelsPetownersSubscribeParams", "fernFilepath": { "allParts": [], "packagePath": [] }, - "name": "ChannelsTestChannelMessagesSendMessage2Data" + "name": "ChannelsPetownersSubscribeParams" }, "shape": { "properties": [ { - "name": "id", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessage2DataId_example_autogenerated": "string" - } - } - }, - { - "name": "name", + "name": "channel", "valueType": { "container": { "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, + "container": { + "literal": { + "string": "petowners", "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessage2DataName_example_autogenerated": "string" - } - } - }, - { - "name": "age", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "INTEGER", - "v2": { - "validation": {}, - "type": "integer" - } + }, + "type": "literal" }, - "type": "primitive" + "type": "container" }, "type": "optional" }, @@ -2147,23 +217,23 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessage2DataAge_example_autogenerated": 1 + "ChannelsPetownersSubscribeParamsChannel_example_autogenerated": "string" } } }, { - "name": "address", + "name": "data", "valueType": { "container": { "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } + "fernFilepath": { + "allParts": [], + "packagePath": [] }, - "type": "primitive" + "name": "ChannelsPetownersSubscribeParamsData", + "typeId": "ChannelsPetownersSubscribeParamsData", + "inline": false, + "type": "named" }, "type": "optional" }, @@ -2172,7 +242,9 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessage2DataAddress_example_autogenerated": "string" + "ChannelsPetownersSubscribeParamsData_example_autogenerated": { + "petOwner": null + } } } } @@ -2189,48 +261,23 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessage2Data_example_autogenerated": {} + "ChannelsPetownersSubscribeParams_example_autogenerated": {} } } }, - "testChannel_sendMessage2": { + "PetownersSubscribe": { "name": { - "typeId": "testChannel_sendMessage2", + "typeId": "PetownersSubscribe", "fernFilepath": { "allParts": [], "packagePath": [] }, - "name": "testChannel_sendMessage2" + "name": "PetownersSubscribe" }, "shape": { "properties": [ { - "name": "message", - "valueType": { - "container": { - "optional": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessage2Message_example_autogenerated": "string" - } - } - }, - { - "name": "data", + "name": "params", "valueType": { "container": { "optional": { @@ -2238,8 +285,8 @@ "allParts": [], "packagePath": [] }, - "name": "ChannelsTestChannelMessagesSendMessage2Data", - "typeId": "ChannelsTestChannelMessagesSendMessage2Data", + "name": "ChannelsPetownersSubscribeParams", + "typeId": "ChannelsPetownersSubscribeParams", "inline": false, "type": "named" }, @@ -2250,7 +297,7 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsTestChannelMessagesSendMessage2Data_example_autogenerated": {} + "ChannelsPetownersSubscribeParams_example_autogenerated": {} } } } @@ -2267,7 +314,7 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "testChannel_sendMessage2_example_autogenerated": {} + "PetownersSubscribe_example_autogenerated": {} } } } @@ -2312,7 +359,7 @@ "autogeneratedExamples": [ { "example": { - "id": "661615ca", + "id": "91844161", "url": "/pet", "endpointHeaders": [], "endpointPathParameters": [], @@ -2320,862 +367,26 @@ "servicePathParameters": [], "serviceHeaders": [], "rootPathParameters": [], - "request": { - "jsonExample": { - "name": "name", - "photoUrls": [ - "photoUrls", - "photoUrls" - ] - }, - "shape": { - "shape": { - "properties": [ - { - "name": "id", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "shape": { - "container": { - "valueType": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "category", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "shape": { - "container": { - "valueType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Category", - "typeId": "Category", - "inline": false, - "displayName": "Pet category", - "type": "named" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "name", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "jsonExample": "name", - "shape": { - "primitive": { - "string": { - "original": "name" - }, - "type": "string" - }, - "type": "primitive" - } - } - }, - { - "name": "photoUrls", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "jsonExample": [ - "photoUrls", - "photoUrls" - ], - "shape": { - "container": { - "list": [ - { - "jsonExample": "photoUrls", - "shape": { - "primitive": { - "string": { - "original": "photoUrls" - }, - "type": "string" - }, - "type": "primitive" - } - }, - { - "jsonExample": "photoUrls", - "shape": { - "primitive": { - "string": { - "original": "photoUrls" - }, - "type": "string" - }, - "type": "primitive" - } - } - ], - "itemType": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "list" - }, - "type": "container" - } - } - }, - { - "name": "tags", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "shape": { - "container": { - "valueType": { - "container": { - "list": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag", - "typeId": "Tag", - "inline": false, - "displayName": "Pet Tag", - "type": "named" - }, - "type": "list" - }, - "type": "container" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "status", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "shape": { - "container": { - "valueType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetStatus", - "typeId": "PetStatus", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - } - } - } - ], - "type": "object" - }, - "typeName": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "type": "named" - }, - "type": "reference" - }, "response": { "value": { "value": { "jsonExample": { - "id": 1000000, - "category": { - "id": 1000000, - "name": "name" - }, - "name": "name", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1000000, - "name": "name" - }, - { - "id": 1000000, - "name": "name" - } - ], - "status": "available" + "key": "value" }, "shape": { - "shape": { - "properties": [ - { - "name": "id", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "jsonExample": 1000000, - "shape": { - "container": { - "optional": { - "jsonExample": 1000000, - "shape": { - "primitive": { - "long": 1000000, - "type": "long" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "category", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "jsonExample": { - "id": 1000000, - "name": "name" - }, - "shape": { - "container": { - "optional": { - "jsonExample": { - "id": 1000000, - "name": "name" - }, - "shape": { - "shape": { - "properties": [ - { - "name": "id", - "originalTypeDeclaration": { - "typeId": "Category", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Category" - }, - "value": { - "jsonExample": 1000000, - "shape": { - "container": { - "optional": { - "jsonExample": 1000000, - "shape": { - "primitive": { - "long": 1000000, - "type": "long" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "name", - "originalTypeDeclaration": { - "typeId": "Category", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Category" - }, - "value": { - "jsonExample": "name", - "shape": { - "container": { - "optional": { - "jsonExample": "name", - "shape": { - "primitive": { - "string": { - "original": "name" - }, - "type": "string" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - } - ], - "type": "object" - }, - "typeName": { - "typeId": "Category", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Category" - }, - "type": "named" - } - }, - "valueType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Category", - "typeId": "Category", - "inline": false, - "displayName": "Pet category", - "type": "named" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "name", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "jsonExample": "name", - "shape": { - "primitive": { - "string": { - "original": "name" - }, - "type": "string" - }, - "type": "primitive" - } - } - }, - { - "name": "photoUrls", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "jsonExample": [ - "photoUrls", - "photoUrls" - ], - "shape": { - "container": { - "list": [ - { - "jsonExample": "photoUrls", - "shape": { - "primitive": { - "string": { - "original": "photoUrls" - }, - "type": "string" - }, - "type": "primitive" - } - }, - { - "jsonExample": "photoUrls", - "shape": { - "primitive": { - "string": { - "original": "photoUrls" - }, - "type": "string" - }, - "type": "primitive" - } - } - ], - "itemType": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "list" - }, - "type": "container" - } - } - }, - { - "name": "tags", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" - }, - "value": { - "jsonExample": [ - { - "id": 1000000, - "name": "name" - }, - { - "id": 1000000, - "name": "name" - } - ], - "shape": { - "container": { - "optional": { - "jsonExample": [ - { - "id": 1000000, - "name": "name" - }, - { - "id": 1000000, - "name": "name" - } - ], - "shape": { - "container": { - "list": [ - { - "jsonExample": { - "id": 1000000, - "name": "name" - }, - "shape": { - "shape": { - "properties": [ - { - "name": "id", - "originalTypeDeclaration": { - "typeId": "Tag", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag" - }, - "value": { - "jsonExample": 1000000, - "shape": { - "container": { - "optional": { - "jsonExample": 1000000, - "shape": { - "primitive": { - "long": 1000000, - "type": "long" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "name", - "originalTypeDeclaration": { - "typeId": "Tag", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag" - }, - "value": { - "jsonExample": "name", - "shape": { - "container": { - "optional": { - "jsonExample": "name", - "shape": { - "primitive": { - "string": { - "original": "name" - }, - "type": "string" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - } - ], - "type": "object" - }, - "typeName": { - "typeId": "Tag", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag" - }, - "type": "named" - } - }, - { - "jsonExample": { - "id": 1000000, - "name": "name" - }, - "shape": { - "shape": { - "properties": [ - { - "name": "id", - "originalTypeDeclaration": { - "typeId": "Tag", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag" - }, - "value": { - "jsonExample": 1000000, - "shape": { - "container": { - "optional": { - "jsonExample": 1000000, - "shape": { - "primitive": { - "long": 1000000, - "type": "long" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "name", - "originalTypeDeclaration": { - "typeId": "Tag", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag" - }, - "value": { - "jsonExample": "name", - "shape": { - "container": { - "optional": { - "jsonExample": "name", - "shape": { - "primitive": { - "string": { - "original": "name" - }, - "type": "string" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - } - ], - "type": "object" - }, - "typeName": { - "typeId": "Tag", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag" - }, - "type": "named" - } - } - ], - "itemType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag", - "typeId": "Tag", - "inline": false, - "displayName": "Pet Tag", - "type": "named" - }, - "type": "list" - }, - "type": "container" - } - }, - "valueType": { - "container": { - "list": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Tag", - "typeId": "Tag", - "inline": false, - "displayName": "Pet Tag", - "type": "named" - }, - "type": "list" - }, - "type": "container" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "status", - "originalTypeDeclaration": { - "typeId": "Pet", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet" + "shape": { + "value": { + "jsonExample": { + "key": "value" + }, + "shape": { + "unknown": { + "key": "value" }, - "value": { - "jsonExample": "available", - "shape": { - "container": { - "optional": { - "jsonExample": "available", - "shape": { - "shape": { - "value": "available", - "type": "enum" - }, - "typeName": { - "typeId": "PetStatus", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetStatus" - }, - "type": "named" - } - }, - "valueType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "PetStatus", - "typeId": "PetStatus", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - } - } + "type": "unknown" } - ], - "type": "object" + }, + "type": "alias" }, "typeName": { "typeId": "Pet", @@ -3207,142 +418,7 @@ "audiences": [], "id": "endpoint_pet.updateAnExistingPet", "name": "updateAnExistingPet", - "requestBody": { - "contentType": "application/json", - "docs": "Pet object that needs to be added to the store", - "requestBodyType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet", - "typeId": "Pet", - "inline": false, - "displayName": "a Pet", - "type": "named" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "petUpdateAnExistingPetExample": { - "id": 0, - "category": { - "id": 6, - "name": "name" - }, - "name": "doggie", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1, - "name": "name" - }, - { - "id": 1, - "name": "name" - } - ], - "status": "available" - } - } - }, - "type": "reference" - }, - "v2RequestBodies": { - "requestBodies": [ - { - "contentType": "application/json", - "docs": "Pet object that needs to be added to the store", - "requestBodyType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet", - "typeId": "Pet", - "inline": false, - "displayName": "a Pet", - "type": "named" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "petUpdateAnExistingPetExample": { - "id": 0, - "category": { - "id": 6, - "name": "name" - }, - "name": "doggie", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1, - "name": "name" - }, - { - "id": 1, - "name": "name" - } - ], - "status": "available" - } - } - }, - "type": "reference" - }, - { - "contentType": "application/xml", - "docs": "Pet object that needs to be added to the store", - "requestBodyType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Pet", - "typeId": "Pet", - "inline": false, - "displayName": "a Pet", - "type": "named" - }, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "petUpdateAnExistingPetExample": { - "id": 0, - "category": { - "id": 6, - "name": "name" - }, - "name": "doggie", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1, - "name": "name" - }, - { - "id": 1, - "name": "name" - } - ], - "status": "available" - } - } - }, - "type": "reference" - } - ] - }, + "v2RequestBodies": {}, "response": { "statusCode": 200, "body": { @@ -3355,36 +431,14 @@ "name": "Pet", "typeId": "Pet", "inline": false, - "displayName": "a Pet", + "displayName": "Pet", "type": "named" }, "docs": "A list of pets", "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "petUpdateAnExistingPetExample": { - "id": 0, - "category": { - "id": 6, - "name": "name" - }, - "name": "doggie", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1, - "name": "name" - }, - { - "id": 1, - "name": "name" - } - ], - "status": "available" - } + "petUpdateAnExistingPetExample": null } }, "type": "response" @@ -3395,8 +449,8 @@ }, "v2Examples": { "autogeneratedExamples": { - "petUpdateAnExistingPetExample_200": { - "displayName": "petUpdateAnExistingPetExample", + "base_petUpdateAnExistingPetExample_200": { + "displayName": "updateAnExistingPetExample", "request": { "endpoint": { "method": "PUT", @@ -3404,57 +458,12 @@ }, "pathParameters": {}, "queryParameters": {}, - "headers": {}, - "requestBody": { - "id": 0, - "category": { - "id": 6, - "name": "name" - }, - "name": "doggie", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1, - "name": "name" - }, - { - "id": 1, - "name": "name" - } - ], - "status": "available" - } + "headers": {} }, "response": { "statusCode": 200, "body": { - "value": { - "id": 0, - "category": { - "id": 6, - "name": "name" - }, - "name": "doggie", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1, - "name": "name" - }, - { - "id": 1, - "name": "name" - } - ], - "status": "available" - }, + "value": null, "type": "json" } } @@ -3476,36 +485,14 @@ "name": "Pet", "typeId": "Pet", "inline": false, - "displayName": "a Pet", + "displayName": "Pet", "type": "named" }, "docs": "A list of pets", "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "petUpdateAnExistingPetExample": { - "id": 0, - "category": { - "id": 6, - "name": "name" - }, - "name": "doggie", - "photoUrls": [ - "photoUrls", - "photoUrls" - ], - "tags": [ - { - "id": 1, - "name": "name" - }, - { - "id": 1, - "name": "name" - } - ], - "status": "available" - } + "petUpdateAnExistingPetExample": null } }, "type": "response" @@ -3554,7 +541,7 @@ "autogeneratedExamples": [ { "example": { - "id": "984010f", + "id": "eef2efd3", "url": "/order", "endpointHeaders": [], "endpointPathParameters": [], @@ -3566,266 +553,22 @@ "value": { "value": { "jsonExample": { - "id": 1000000, - "petId": 1000000, - "quantity": 1, - "shipDate": "2024-01-15T09:30:00Z", - "status": "placed", - "complete": true + "key": "value" }, "shape": { "shape": { - "properties": [ - { - "name": "id", - "originalTypeDeclaration": { - "typeId": "Order", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Order" - }, - "value": { - "jsonExample": 1000000, - "shape": { - "container": { - "optional": { - "jsonExample": 1000000, - "shape": { - "primitive": { - "long": 1000000, - "type": "long" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "petId", - "originalTypeDeclaration": { - "typeId": "Order", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Order" - }, - "value": { - "jsonExample": 1000000, - "shape": { - "container": { - "optional": { - "jsonExample": 1000000, - "shape": { - "primitive": { - "long": 1000000, - "type": "long" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "LONG", - "v2": { - "validation": {}, - "type": "long" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "quantity", - "originalTypeDeclaration": { - "typeId": "Order", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Order" - }, - "value": { - "jsonExample": 1, - "shape": { - "container": { - "optional": { - "jsonExample": 1, - "shape": { - "primitive": { - "integer": 1, - "type": "integer" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "INTEGER", - "v2": { - "validation": {}, - "type": "integer" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "shipDate", - "originalTypeDeclaration": { - "typeId": "Order", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Order" - }, - "value": { - "jsonExample": "2024-01-15T09:30:00Z", - "shape": { - "container": { - "optional": { - "jsonExample": "2024-01-15T09:30:00Z", - "shape": { - "primitive": { - "datetime": "2024-01-15T09:30:00.000Z", - "raw": "2024-01-15T09:30:00Z", - "type": "datetime" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "DATE_TIME", - "v2": { - "type": "dateTime" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "status", - "originalTypeDeclaration": { - "typeId": "Order", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Order" - }, - "value": { - "jsonExample": "placed", - "shape": { - "container": { - "optional": { - "jsonExample": "placed", - "shape": { - "shape": { - "value": "placed", - "type": "enum" - }, - "typeName": { - "typeId": "OrderStatus", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "OrderStatus" - }, - "type": "named" - } - }, - "valueType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "OrderStatus", - "typeId": "OrderStatus", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - } - } + "value": { + "jsonExample": { + "key": "value" }, - { - "name": "complete", - "originalTypeDeclaration": { - "typeId": "Order", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Order" + "shape": { + "unknown": { + "key": "value" }, - "value": { - "jsonExample": true, - "shape": { - "container": { - "optional": { - "jsonExample": true, - "shape": { - "primitive": { - "boolean": true, - "type": "boolean" - }, - "type": "primitive" - } - }, - "valueType": { - "primitive": { - "v1": "BOOLEAN", - "v2": { - "default": false, - "type": "boolean" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } + "type": "unknown" } - ], - "type": "object" + }, + "type": "alias" }, "typeName": { "typeId": "Order", @@ -3870,21 +613,14 @@ "name": "Order", "typeId": "Order", "inline": false, - "displayName": "Pet Order", + "displayName": "Order", "type": "named" }, "docs": "An order object", "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "orderGetAnOrderExample": { - "id": 0, - "petId": 6, - "quantity": 1, - "shipDate": "2000-01-23T04:56:07.000+00:00", - "status": "placed", - "complete": false - } + "orderGetAnOrderExample": null } }, "type": "response" @@ -3909,14 +645,7 @@ "response": { "statusCode": 200, "body": { - "value": { - "id": 0, - "petId": 6, - "quantity": 1, - "shipDate": "2000-01-23T04:56:07.000+00:00", - "status": "placed", - "complete": false - }, + "value": null, "type": "json" } } @@ -3938,21 +667,14 @@ "name": "Order", "typeId": "Order", "inline": false, - "displayName": "Pet Order", + "displayName": "Order", "type": "named" }, "docs": "An order object", "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "orderGetAnOrderExample": { - "id": 0, - "petId": 6, - "quantity": 1, - "shipDate": "2000-01-23T04:56:07.000+00:00", - "status": "placed", - "complete": false - } + "orderGetAnOrderExample": null } }, "type": "response" @@ -4246,178 +968,14 @@ "pathParameters": [], "headers": [], "queryParameters": [], - "messages": [ - { - "type": "send", - "body": { - "jsonExample": {}, - "shape": { - "shape": { - "properties": [ - { - "name": "message", - "originalTypeDeclaration": { - "typeId": "testChannel_sendMessage", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "testChannel_sendMessage" - }, - "value": { - "shape": { - "container": { - "valueType": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "data", - "originalTypeDeclaration": { - "typeId": "testChannel_sendMessage", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "testChannel_sendMessage" - }, - "value": { - "shape": { - "container": { - "valueType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "ChannelsTestChannelMessagesSendMessageData", - "typeId": "ChannelsTestChannelMessagesSendMessageData", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - } - } - } - ], - "type": "object" - }, - "typeName": { - "typeId": "testChannel_sendMessage", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "testChannel_sendMessage" - }, - "type": "named" - }, - "type": "reference" - } - } - ], + "messages": [], "url": "/test" }, { "pathParameters": [], "headers": [], "queryParameters": [], - "messages": [ - { - "type": "send", - "body": { - "jsonExample": {}, - "shape": { - "shape": { - "properties": [ - { - "name": "message", - "originalTypeDeclaration": { - "typeId": "testChannel_sendMessage", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "testChannel_sendMessage" - }, - "value": { - "shape": { - "container": { - "valueType": { - "primitive": { - "v1": "STRING", - "v2": { - "validation": {}, - "type": "string" - } - }, - "type": "primitive" - }, - "type": "optional" - }, - "type": "container" - } - } - }, - { - "name": "data", - "originalTypeDeclaration": { - "typeId": "testChannel_sendMessage", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "testChannel_sendMessage" - }, - "value": { - "shape": { - "container": { - "valueType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "ChannelsTestChannelMessagesSendMessageData", - "typeId": "ChannelsTestChannelMessagesSendMessageData", - "inline": false, - "type": "named" - }, - "type": "optional" - }, - "type": "container" - } - } - } - ], - "type": "object" - }, - "typeName": { - "typeId": "testChannel_sendMessage", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "testChannel_sendMessage" - }, - "type": "named" - }, - "type": "reference" - } - } - ], + "messages": [], "url": "/test" } ], @@ -4433,12 +991,7 @@ "Pet", "PetOwner", "Order", - "Category", - "Tag", - "User", - "PetOwner", - "testChannel_sendMessage", - "testChannel_sendMessage2" + "PetOwner" ], "errors": [], "subpackages": [ diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/fern/fern.config.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/fern/fern.config.json new file mode 100644 index 000000000000..ecb7133e2645 --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/fern/fern.config.json @@ -0,0 +1,4 @@ +{ + "organization": "fern", + "version": "*" +} diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/fern/generators.yml b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/fern/generators.yml new file mode 100644 index 000000000000..5b01f1e0833d --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/fern/generators.yml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +api: + specs: + - openapi: ../openapi.yml diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/openapi.yml b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/openapi.yml new file mode 100644 index 000000000000..66ce8d4ceca1 --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/allof-nested-ref/openapi.yml @@ -0,0 +1,221 @@ +openapi: 3.0.3 +info: + title: Nested allOf $ref Resolution + version: 1.0.0 + description: > + Exercises nested allOf $ref resolution where a parent schema itself uses + allOf with $ref elements. The v3 parser must recursively resolve these + nested references during allOf flattening to include grandparent + properties in the merged result. + +servers: + - url: https://api.example.com + +paths: + /users: + post: + operationId: createUser + summary: Create a user (three-level allOf chain) + description: > + Tests the core BigCommerce pattern: userPost -> userBase -> userStrict. + The grandparent's properties must be resolved through the nested $ref. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserPost" + responses: + "200": + description: Created user + content: + application/json: + schema: + $ref: "#/components/schemas/UserStrict" + + /companies: + post: + operationId: createCompany + summary: Create a company (sibling properties alongside nested $ref) + description: > + Tests that sibling properties at the parent level (companyBase has + its own properties in addition to $ref) are all preserved. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CompanyPost" + responses: + "200": + description: Created company + content: + application/json: + schema: + $ref: "#/components/schemas/CompanyBase" + + /plants: + post: + operationId: createPlant + summary: Create a plant (multiple $ref in single allOf) + description: > + Tests that when a parent's allOf contains multiple $ref entries, + all of them are resolved and their properties merged. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PlantRecord" + responses: + "200": + description: Created plant + content: + application/json: + schema: + $ref: "#/components/schemas/PlantRecord" + +components: + schemas: + # =================================================================== + # Case 1: Three-level chain (BigCommerce pattern) + # UserPost -> UserBase -> UserStrict + # =================================================================== + UserStrict: + type: object + required: + - firstName + - lastName + - email + properties: + firstName: + type: string + lastName: + type: string + email: + type: string + format: email + + UserBase: + allOf: + - $ref: "#/components/schemas/UserStrict" + - type: object + properties: + role: + type: string + description: The user's role + companyId: + type: integer + description: Associated company ID + + UserPost: + allOf: + - $ref: "#/components/schemas/UserBase" + - required: + - firstName + - lastName + - email + - companyId + - role + - channelIds + properties: + acceptWelcomeEmail: + type: boolean + description: Whether to send a welcome email + originChannelId: + type: integer + description: Origin channel ID + channelIds: + type: array + items: + type: integer + description: List of channel IDs + + # =================================================================== + # Case 2: Sibling properties alongside nested $ref + # CompanyPost -> CompanyBase -> AddressBase + # CompanyBase has its own properties PLUS a $ref to AddressBase + # =================================================================== + AddressBase: + type: object + required: + - country + properties: + country: + type: string + state: + type: string + city: + type: string + + CompanyBase: + type: object + properties: + companyName: + type: string + companyPhone: + type: string + allOf: + - $ref: "#/components/schemas/AddressBase" + + CompanyPost: + allOf: + - $ref: "#/components/schemas/CompanyBase" + - required: + - companyName + - companyPhone + - country + properties: + taxId: + type: string + description: Tax identification number + + # =================================================================== + # Case 3: Multiple $ref in a single allOf + # PlantRecord -> PlantBase (which has allOf: [$ref: Identifiable, $ref: Describable, inline]) + # =================================================================== + Identifiable: + type: object + required: + - id + properties: + id: + type: string + format: uuid + + Describable: + type: object + properties: + name: + type: string + description: + type: string + + PlantBase: + allOf: + - $ref: "#/components/schemas/Identifiable" + - $ref: "#/components/schemas/Describable" + - type: object + properties: + species: + type: string + wateringFrequency: + type: string + enum: + - daily + - weekly + - biweekly + - monthly + + PlantRecord: + allOf: + - $ref: "#/components/schemas/PlantBase" + - required: + - id + - name + - species + properties: + plantedAt: + type: string + format: date + description: Date the plant was planted diff --git a/packages/cli/cli/changes/unreleased/fix-allof-nested-ref-resolution.yml b/packages/cli/cli/changes/unreleased/fix-allof-nested-ref-resolution.yml new file mode 100644 index 000000000000..b8520134bb9b --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-allof-nested-ref-resolution.yml @@ -0,0 +1,6 @@ +- summary: | + Fix docs rendering dropping parent properties when an allOf parent schema + itself uses allOf with $ref elements. The v3 OpenAPI parser now recursively + resolves nested $ref objects during allOf flattening instead of silently + filtering them out. + type: fix diff --git a/seed/go-sdk/allof/.fern/metadata.json b/seed/go-sdk/allof/.fern/metadata.json index 4325dc2a3860..5b82336b7636 100644 --- a/seed/go-sdk/allof/.fern/metadata.json +++ b/seed/go-sdk/allof/.fern/metadata.json @@ -6,8 +6,7 @@ "enableWireTests": false }, "originGitCommit": "DUMMY", - "invokedBy": "ci", + "invokedBy": "manual", "requestedVersion": "0.0.1", - "ciProvider": "github", "sdkVersion": "v0.0.1" } \ No newline at end of file diff --git a/seed/go-sdk/allof/client/client.go b/seed/go-sdk/allof/client/client.go index 999dc901a0c7..4e3c249fea58 100644 --- a/seed/go-sdk/allof/client/client.go +++ b/seed/go-sdk/allof/client/client.go @@ -108,3 +108,37 @@ func (c *Client) GetOrganization( } return response.Body, nil } + +// Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +func (c *Client) CreatePlant( + ctx context.Context, + request *fern.PlantPost, + opts ...option.RequestOption, +) (*fern.PlantStrict, error) { + response, err := c.WithRawResponse.CreatePlant( + ctx, + request, + opts..., + ) + if err != nil { + return nil, err + } + return response.Body, nil +} + +// Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +func (c *Client) CreateTree( + ctx context.Context, + request *fern.TreeRecord, + opts ...option.RequestOption, +) (*fern.TreeRecord, error) { + response, err := c.WithRawResponse.CreateTree( + ctx, + request, + opts..., + ) + if err != nil { + return nil, err + } + return response.Body, nil +} diff --git a/seed/go-sdk/allof/client/raw_client.go b/seed/go-sdk/allof/client/raw_client.go index eb824606e719..dc47f3d7dc1e 100644 --- a/seed/go-sdk/allof/client/raw_client.go +++ b/seed/go-sdk/allof/client/raw_client.go @@ -242,3 +242,88 @@ func (r *RawClient) GetOrganization( Body: response, }, nil } + +func (r *RawClient) CreatePlant( + ctx context.Context, + request *fern.PlantPost, + opts ...option.RequestOption, +) (*core.Response[*fern.PlantStrict], error) { + options := core.NewRequestOptions(opts...) + baseURL := internal.ResolveBaseURL( + options.BaseURL, + r.baseURL, + "https://api.example.com", + ) + endpointURL := baseURL + "/plants" + headers := internal.MergeHeaders( + r.options.ToHeader(), + options.ToHeader(), + ) + headers.Add("Content-Type", "application/json") + var response *fern.PlantStrict + raw, err := r.caller.Call( + ctx, + &internal.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + Headers: headers, + MaxAttempts: options.MaxAttempts, + DisableRetries: options.DisableRetries, + BodyProperties: options.BodyProperties, + QueryParameters: options.QueryParameters, + Client: options.HTTPClient, + Request: request, + Response: &response, + }, + ) + if err != nil { + return nil, err + } + return &core.Response[*fern.PlantStrict]{ + StatusCode: raw.StatusCode, + Header: raw.Header, + Body: response, + }, nil +} + +func (r *RawClient) CreateTree( + ctx context.Context, + request *fern.TreeRecord, + opts ...option.RequestOption, +) (*core.Response[*fern.TreeRecord], error) { + options := core.NewRequestOptions(opts...) + baseURL := internal.ResolveBaseURL( + options.BaseURL, + r.baseURL, + "https://api.example.com", + ) + endpointURL := baseURL + "/trees" + headers := internal.MergeHeaders( + r.options.ToHeader(), + options.ToHeader(), + ) + var response *fern.TreeRecord + raw, err := r.caller.Call( + ctx, + &internal.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + Headers: headers, + MaxAttempts: options.MaxAttempts, + DisableRetries: options.DisableRetries, + BodyProperties: options.BodyProperties, + QueryParameters: options.QueryParameters, + Client: options.HTTPClient, + Request: request, + Response: &response, + }, + ) + if err != nil { + return nil, err + } + return &core.Response[*fern.TreeRecord]{ + StatusCode: raw.StatusCode, + Header: raw.Header, + Body: response, + }, nil +} diff --git a/seed/go-sdk/allof/dynamic-snippets/example10/snippet.go b/seed/go-sdk/allof/dynamic-snippets/example10/snippet.go new file mode 100644 index 000000000000..acfbf81f70c6 --- /dev/null +++ b/seed/go-sdk/allof/dynamic-snippets/example10/snippet.go @@ -0,0 +1,27 @@ +package example + +import ( + context "context" + + fern "github.com/allof/fern" + client "github.com/allof/fern/client" + option "github.com/allof/fern/option" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + ) + request := &fern.PlantPost{ + Species: "species", + Family: "family", + Genus: "genus", + SunExposure: fern.PlantPostSunExposureFull, + } + client.CreatePlant( + context.TODO(), + request, + ) +} diff --git a/seed/go-sdk/allof/dynamic-snippets/example11/snippet.go b/seed/go-sdk/allof/dynamic-snippets/example11/snippet.go new file mode 100644 index 000000000000..461af52d6691 --- /dev/null +++ b/seed/go-sdk/allof/dynamic-snippets/example11/snippet.go @@ -0,0 +1,39 @@ +package example + +import ( + context "context" + + fern "github.com/allof/fern" + client "github.com/allof/fern/client" + option "github.com/allof/fern/option" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + ) + request := &fern.PlantPost{ + CommonName: fern.String( + "commonName", + ), + WateringFrequency: fern.PlantBaseWateringFrequencyDaily.Ptr(), + Species: "species", + Family: "family", + Genus: "genus", + SunExposure: fern.PlantPostSunExposureFull, + PlantedAt: fern.Time( + fern.MustParseDate( + "2023-01-15", + ), + ), + SoilType: fern.String( + "soilType", + ), + } + client.CreatePlant( + context.TODO(), + request, + ) +} diff --git a/seed/go-sdk/allof/dynamic-snippets/example12/snippet.go b/seed/go-sdk/allof/dynamic-snippets/example12/snippet.go new file mode 100644 index 000000000000..05ef5286b698 --- /dev/null +++ b/seed/go-sdk/allof/dynamic-snippets/example12/snippet.go @@ -0,0 +1,24 @@ +package example + +import ( + context "context" + + fern "github.com/allof/fern" + client "github.com/allof/fern/client" + option "github.com/allof/fern/option" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + ) + request := &fern.TreeRecord{ + ID: "id", + } + client.CreateTree( + context.TODO(), + request, + ) +} diff --git a/seed/go-sdk/allof/dynamic-snippets/example13/snippet.go b/seed/go-sdk/allof/dynamic-snippets/example13/snippet.go new file mode 100644 index 000000000000..52926472b866 --- /dev/null +++ b/seed/go-sdk/allof/dynamic-snippets/example13/snippet.go @@ -0,0 +1,41 @@ +package example + +import ( + context "context" + + fern "github.com/allof/fern" + client "github.com/allof/fern/client" + option "github.com/allof/fern/option" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + ) + request := &fern.TreeRecord{ + TreeSpecies: fern.String( + "treeSpecies", + ), + HeightInFeet: fern.Float64( + 1.1, + ), + ID: "id", + TreeName: fern.String( + "treeName", + ), + TreeDescription: fern.String( + "treeDescription", + ), + PlantedDate: fern.Time( + fern.MustParseDate( + "2023-01-15", + ), + ), + } + client.CreateTree( + context.TODO(), + request, + ) +} diff --git a/seed/go-sdk/allof/reference.md b/seed/go-sdk/allof/reference.md index ef635f89f996..aa671d86ed75 100644 --- a/seed/go-sdk/allof/reference.md +++ b/seed/go-sdk/allof/reference.md @@ -184,3 +184,144 @@ client.GetOrganization( +

client.CreatePlant(request) -> *fern.PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```go +request := &fern.PlantPost{ + Species: "species", + Family: "family", + Genus: "genus", + SunExposure: fern.PlantPostSunExposureFull, + } +client.CreatePlant( + context.TODO(), + request, + ) +} +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**sunExposure:** `*fern.PlantPostSunExposure` — Required sun exposure level. + +
+
+ +
+
+ +**plantedAt:** `*time.Time` — Date the plant was planted. + +
+
+ +
+
+ +**soilType:** `*string` — Preferred soil type. + +
+
+
+
+ + +
+
+
+ +
client.CreateTree(request) -> *fern.TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```go +request := &fern.TreeRecord{ + ID: "id", + } +client.CreateTree( + context.TODO(), + request, + ) +} +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `*fern.TreeRecord` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/go-sdk/allof/snippet.json b/seed/go-sdk/allof/snippet.json index a2cd78807ad3..c7559d1afc58 100644 --- a/seed/go-sdk/allof/snippet.json +++ b/seed/go-sdk/allof/snippet.json @@ -22,6 +22,17 @@ "client": "import (\n\tcontext \"context\"\n\tfernclient \"github.com/allof/fern/client\"\n)\n\nclient := fernclient.NewClient()\nresponse, err := client.GetOrganization(\n\tcontext.TODO(),\n)\n" } }, + { + "id": { + "path": "/plants", + "method": "POST", + "identifier_override": "endpoint_.createPlant" + }, + "snippet": { + "type": "go", + "client": "import (\n\tcontext \"context\"\n\tfern \"github.com/allof/fern\"\n\tfernclient \"github.com/allof/fern/client\"\n)\n\nclient := fernclient.NewClient()\nresponse, err := client.CreatePlant(\n\tcontext.TODO(),\n\t\u0026fern.PlantPost{\n\t\tSpecies: \"species\",\n\t\tFamily: \"family\",\n\t\tGenus: \"genus\",\n\t\tSunExposure: fern.PlantPostSunExposureFull,\n\t},\n)\n" + } + }, { "id": { "path": "/rule-types", @@ -44,6 +55,17 @@ "client": "import (\n\tcontext \"context\"\n\tfern \"github.com/allof/fern\"\n\tfernclient \"github.com/allof/fern/client\"\n)\n\nclient := fernclient.NewClient()\nresponse, err := client.CreateRule(\n\tcontext.TODO(),\n\t\u0026fern.RuleCreateRequest{\n\t\tName: \"name\",\n\t\tExecutionContext: fern.RuleCreateRequestExecutionContextProd,\n\t},\n)\n" } }, + { + "id": { + "path": "/trees", + "method": "POST", + "identifier_override": "endpoint_.createTree" + }, + "snippet": { + "type": "go", + "client": "import (\n\tcontext \"context\"\n\tfern \"github.com/allof/fern\"\n\tfernclient \"github.com/allof/fern/client\"\n)\n\nclient := fernclient.NewClient()\nresponse, err := client.CreateTree(\n\tcontext.TODO(),\n\t\u0026fern.TreeRecord{\n\t\tID: \"id\",\n\t},\n)\n" + } + }, { "id": { "path": "/users", diff --git a/seed/go-sdk/allof/types.go b/seed/go-sdk/allof/types.go index 5a07dac37a37..e1f90056b6c5 100644 --- a/seed/go-sdk/allof/types.go +++ b/seed/go-sdk/allof/types.go @@ -10,6 +10,84 @@ import ( time "time" ) +var ( + plantPostFieldSunExposure = big.NewInt(1 << 0) + plantPostFieldPlantedAt = big.NewInt(1 << 1) + plantPostFieldSoilType = big.NewInt(1 << 2) +) + +type PlantPost struct { + // The botanical species name. + Species string `json:"species" url:"-"` + // The botanical family. + Family string `json:"family" url:"-"` + // The botanical genus. + Genus string `json:"genus" url:"-"` + // The common name of the plant. + CommonName *string `json:"commonName,omitempty" url:"-"` + WateringFrequency *PlantBaseWateringFrequency `json:"wateringFrequency,omitempty" url:"-"` + // Required sun exposure level. + SunExposure PlantPostSunExposure `json:"sunExposure" url:"-"` + // Date the plant was planted. + PlantedAt *time.Time `json:"plantedAt,omitempty" url:"-" format:"date"` + // Preferred soil type. + SoilType *string `json:"soilType,omitempty" url:"-"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` +} + +func (p *PlantPost) require(field *big.Int) { + if p.explicitFields == nil { + p.explicitFields = big.NewInt(0) + } + p.explicitFields.Or(p.explicitFields, field) +} + +// SetSunExposure sets the SunExposure field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetSunExposure(sunExposure PlantPostSunExposure) { + p.SunExposure = sunExposure + p.require(plantPostFieldSunExposure) +} + +// SetPlantedAt sets the PlantedAt field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetPlantedAt(plantedAt *time.Time) { + p.PlantedAt = plantedAt + p.require(plantPostFieldPlantedAt) +} + +// SetSoilType sets the SoilType field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetSoilType(soilType *string) { + p.SoilType = soilType + p.require(plantPostFieldSoilType) +} + +func (p *PlantPost) UnmarshalJSON(data []byte) error { + type unmarshaler PlantPost + var body unmarshaler + if err := json.Unmarshal(data, &body); err != nil { + return err + } + *p = PlantPost(body) + return nil +} + +func (p *PlantPost) MarshalJSON() ([]byte, error) { + type embed PlantPost + var marshaler = struct { + embed + PlantedAt *internal.Date `json:"plantedAt,omitempty"` + }{ + embed: embed(*p), + PlantedAt: internal.NewOptionalDate(p.PlantedAt), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, p.explicitFields) + return json.Marshal(explicitMarshaler) +} + var ( ruleCreateRequestFieldName = big.NewInt(1 << 0) ruleCreateRequestFieldExecutionContext = big.NewInt(1 << 1) @@ -1308,6 +1386,331 @@ func (p *PagingCursors) String() string { return fmt.Sprintf("%#v", p) } +var ( + plantBaseFieldSpecies = big.NewInt(1 << 0) + plantBaseFieldFamily = big.NewInt(1 << 1) + plantBaseFieldGenus = big.NewInt(1 << 2) + plantBaseFieldCommonName = big.NewInt(1 << 3) + plantBaseFieldWateringFrequency = big.NewInt(1 << 4) +) + +type PlantBase struct { + // The botanical species name. + Species string `json:"species" url:"species"` + // The botanical family. + Family string `json:"family" url:"family"` + // The botanical genus. + Genus string `json:"genus" url:"genus"` + // The common name of the plant. + CommonName *string `json:"commonName,omitempty" url:"commonName,omitempty"` + WateringFrequency *PlantBaseWateringFrequency `json:"wateringFrequency,omitempty" url:"wateringFrequency,omitempty"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (p *PlantBase) GetSpecies() string { + if p == nil { + return "" + } + return p.Species +} + +func (p *PlantBase) GetFamily() string { + if p == nil { + return "" + } + return p.Family +} + +func (p *PlantBase) GetGenus() string { + if p == nil { + return "" + } + return p.Genus +} + +func (p *PlantBase) GetCommonName() *string { + if p == nil { + return nil + } + return p.CommonName +} + +func (p *PlantBase) GetWateringFrequency() *PlantBaseWateringFrequency { + if p == nil { + return nil + } + return p.WateringFrequency +} + +func (p *PlantBase) GetExtraProperties() map[string]interface{} { + if p == nil { + return nil + } + return p.extraProperties +} + +func (p *PlantBase) require(field *big.Int) { + if p.explicitFields == nil { + p.explicitFields = big.NewInt(0) + } + p.explicitFields.Or(p.explicitFields, field) +} + +// SetSpecies sets the Species field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantBase) SetSpecies(species string) { + p.Species = species + p.require(plantBaseFieldSpecies) +} + +// SetFamily sets the Family field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantBase) SetFamily(family string) { + p.Family = family + p.require(plantBaseFieldFamily) +} + +// SetGenus sets the Genus field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantBase) SetGenus(genus string) { + p.Genus = genus + p.require(plantBaseFieldGenus) +} + +// SetCommonName sets the CommonName field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantBase) SetCommonName(commonName *string) { + p.CommonName = commonName + p.require(plantBaseFieldCommonName) +} + +// SetWateringFrequency sets the WateringFrequency field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantBase) SetWateringFrequency(wateringFrequency *PlantBaseWateringFrequency) { + p.WateringFrequency = wateringFrequency + p.require(plantBaseFieldWateringFrequency) +} + +func (p *PlantBase) UnmarshalJSON(data []byte) error { + type unmarshaler PlantBase + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *p = PlantBase(value) + extraProperties, err := internal.ExtractExtraProperties(data, *p) + if err != nil { + return err + } + p.extraProperties = extraProperties + p.rawJSON = json.RawMessage(data) + return nil +} + +func (p *PlantBase) MarshalJSON() ([]byte, error) { + type embed PlantBase + var marshaler = struct { + embed + }{ + embed: embed(*p), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, p.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (p *PlantBase) String() string { + if p == nil { + return "" + } + if len(p.rawJSON) > 0 { + if value, err := internal.StringifyJSON(p.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(p); err == nil { + return value + } + return fmt.Sprintf("%#v", p) +} + +type PlantBaseWateringFrequency string + +const ( + PlantBaseWateringFrequencyDaily PlantBaseWateringFrequency = "daily" + PlantBaseWateringFrequencyWeekly PlantBaseWateringFrequency = "weekly" + PlantBaseWateringFrequencyBiweekly PlantBaseWateringFrequency = "biweekly" + PlantBaseWateringFrequencyMonthly PlantBaseWateringFrequency = "monthly" +) + +func NewPlantBaseWateringFrequencyFromString(s string) (PlantBaseWateringFrequency, error) { + switch s { + case "daily": + return PlantBaseWateringFrequencyDaily, nil + case "weekly": + return PlantBaseWateringFrequencyWeekly, nil + case "biweekly": + return PlantBaseWateringFrequencyBiweekly, nil + case "monthly": + return PlantBaseWateringFrequencyMonthly, nil + } + var t PlantBaseWateringFrequency + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (p PlantBaseWateringFrequency) Ptr() *PlantBaseWateringFrequency { + return &p +} + +// Required sun exposure level. +type PlantPostSunExposure string + +const ( + PlantPostSunExposureFull PlantPostSunExposure = "full" + PlantPostSunExposurePartial PlantPostSunExposure = "partial" + PlantPostSunExposureShade PlantPostSunExposure = "shade" +) + +func NewPlantPostSunExposureFromString(s string) (PlantPostSunExposure, error) { + switch s { + case "full": + return PlantPostSunExposureFull, nil + case "partial": + return PlantPostSunExposurePartial, nil + case "shade": + return PlantPostSunExposureShade, nil + } + var t PlantPostSunExposure + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (p PlantPostSunExposure) Ptr() *PlantPostSunExposure { + return &p +} + +var ( + plantStrictFieldSpecies = big.NewInt(1 << 0) + plantStrictFieldFamily = big.NewInt(1 << 1) + plantStrictFieldGenus = big.NewInt(1 << 2) +) + +type PlantStrict struct { + // The botanical species name. + Species string `json:"species" url:"species"` + // The botanical family. + Family string `json:"family" url:"family"` + // The botanical genus. + Genus string `json:"genus" url:"genus"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (p *PlantStrict) GetSpecies() string { + if p == nil { + return "" + } + return p.Species +} + +func (p *PlantStrict) GetFamily() string { + if p == nil { + return "" + } + return p.Family +} + +func (p *PlantStrict) GetGenus() string { + if p == nil { + return "" + } + return p.Genus +} + +func (p *PlantStrict) GetExtraProperties() map[string]interface{} { + if p == nil { + return nil + } + return p.extraProperties +} + +func (p *PlantStrict) require(field *big.Int) { + if p.explicitFields == nil { + p.explicitFields = big.NewInt(0) + } + p.explicitFields.Or(p.explicitFields, field) +} + +// SetSpecies sets the Species field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantStrict) SetSpecies(species string) { + p.Species = species + p.require(plantStrictFieldSpecies) +} + +// SetFamily sets the Family field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantStrict) SetFamily(family string) { + p.Family = family + p.require(plantStrictFieldFamily) +} + +// SetGenus sets the Genus field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantStrict) SetGenus(genus string) { + p.Genus = genus + p.require(plantStrictFieldGenus) +} + +func (p *PlantStrict) UnmarshalJSON(data []byte) error { + type unmarshaler PlantStrict + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *p = PlantStrict(value) + extraProperties, err := internal.ExtractExtraProperties(data, *p) + if err != nil { + return err + } + p.extraProperties = extraProperties + p.rawJSON = json.RawMessage(data) + return nil +} + +func (p *PlantStrict) MarshalJSON() ([]byte, error) { + type embed PlantStrict + var marshaler = struct { + embed + }{ + embed: embed(*p), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, p.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (p *PlantStrict) String() string { + if p == nil { + return "" + } + if len(p.rawJSON) > 0 { + if value, err := internal.StringifyJSON(p.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(p); err == nil { + return value + } + return fmt.Sprintf("%#v", p) +} + // Execution context for the rule, excluding the prod environment. type RuleCreateRequestExecutionContext string @@ -1814,6 +2217,524 @@ func (r *RuleTypeSearchResponse) String() string { return fmt.Sprintf("%#v", r) } +var ( + treeBaseFieldID = big.NewInt(1 << 0) + treeBaseFieldTreeName = big.NewInt(1 << 1) + treeBaseFieldTreeDescription = big.NewInt(1 << 2) + treeBaseFieldTreeSpecies = big.NewInt(1 << 3) + treeBaseFieldHeightInFeet = big.NewInt(1 << 4) +) + +type TreeBase struct { + // Unique tree identifier. + ID string `json:"id" url:"id"` + // Display name of the tree. + TreeName *string `json:"treeName,omitempty" url:"treeName,omitempty"` + // A description of the tree. + TreeDescription *string `json:"treeDescription,omitempty" url:"treeDescription,omitempty"` + // The species of tree. + TreeSpecies *string `json:"treeSpecies,omitempty" url:"treeSpecies,omitempty"` + // Height of the tree in feet. + HeightInFeet *float64 `json:"heightInFeet,omitempty" url:"heightInFeet,omitempty"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (t *TreeBase) GetID() string { + if t == nil { + return "" + } + return t.ID +} + +func (t *TreeBase) GetTreeName() *string { + if t == nil { + return nil + } + return t.TreeName +} + +func (t *TreeBase) GetTreeDescription() *string { + if t == nil { + return nil + } + return t.TreeDescription +} + +func (t *TreeBase) GetTreeSpecies() *string { + if t == nil { + return nil + } + return t.TreeSpecies +} + +func (t *TreeBase) GetHeightInFeet() *float64 { + if t == nil { + return nil + } + return t.HeightInFeet +} + +func (t *TreeBase) GetExtraProperties() map[string]interface{} { + if t == nil { + return nil + } + return t.extraProperties +} + +func (t *TreeBase) require(field *big.Int) { + if t.explicitFields == nil { + t.explicitFields = big.NewInt(0) + } + t.explicitFields.Or(t.explicitFields, field) +} + +// SetID sets the ID field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeBase) SetID(id string) { + t.ID = id + t.require(treeBaseFieldID) +} + +// SetTreeName sets the TreeName field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeBase) SetTreeName(treeName *string) { + t.TreeName = treeName + t.require(treeBaseFieldTreeName) +} + +// SetTreeDescription sets the TreeDescription field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeBase) SetTreeDescription(treeDescription *string) { + t.TreeDescription = treeDescription + t.require(treeBaseFieldTreeDescription) +} + +// SetTreeSpecies sets the TreeSpecies field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeBase) SetTreeSpecies(treeSpecies *string) { + t.TreeSpecies = treeSpecies + t.require(treeBaseFieldTreeSpecies) +} + +// SetHeightInFeet sets the HeightInFeet field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeBase) SetHeightInFeet(heightInFeet *float64) { + t.HeightInFeet = heightInFeet + t.require(treeBaseFieldHeightInFeet) +} + +func (t *TreeBase) UnmarshalJSON(data []byte) error { + type unmarshaler TreeBase + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *t = TreeBase(value) + extraProperties, err := internal.ExtractExtraProperties(data, *t) + if err != nil { + return err + } + t.extraProperties = extraProperties + t.rawJSON = json.RawMessage(data) + return nil +} + +func (t *TreeBase) MarshalJSON() ([]byte, error) { + type embed TreeBase + var marshaler = struct { + embed + }{ + embed: embed(*t), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, t.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (t *TreeBase) String() string { + if t == nil { + return "" + } + if len(t.rawJSON) > 0 { + if value, err := internal.StringifyJSON(t.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(t); err == nil { + return value + } + return fmt.Sprintf("%#v", t) +} + +var ( + treeDescribableFieldTreeName = big.NewInt(1 << 0) + treeDescribableFieldTreeDescription = big.NewInt(1 << 1) +) + +type TreeDescribable struct { + // Display name of the tree. + TreeName *string `json:"treeName,omitempty" url:"treeName,omitempty"` + // A description of the tree. + TreeDescription *string `json:"treeDescription,omitempty" url:"treeDescription,omitempty"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (t *TreeDescribable) GetTreeName() *string { + if t == nil { + return nil + } + return t.TreeName +} + +func (t *TreeDescribable) GetTreeDescription() *string { + if t == nil { + return nil + } + return t.TreeDescription +} + +func (t *TreeDescribable) GetExtraProperties() map[string]interface{} { + if t == nil { + return nil + } + return t.extraProperties +} + +func (t *TreeDescribable) require(field *big.Int) { + if t.explicitFields == nil { + t.explicitFields = big.NewInt(0) + } + t.explicitFields.Or(t.explicitFields, field) +} + +// SetTreeName sets the TreeName field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeDescribable) SetTreeName(treeName *string) { + t.TreeName = treeName + t.require(treeDescribableFieldTreeName) +} + +// SetTreeDescription sets the TreeDescription field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeDescribable) SetTreeDescription(treeDescription *string) { + t.TreeDescription = treeDescription + t.require(treeDescribableFieldTreeDescription) +} + +func (t *TreeDescribable) UnmarshalJSON(data []byte) error { + type unmarshaler TreeDescribable + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *t = TreeDescribable(value) + extraProperties, err := internal.ExtractExtraProperties(data, *t) + if err != nil { + return err + } + t.extraProperties = extraProperties + t.rawJSON = json.RawMessage(data) + return nil +} + +func (t *TreeDescribable) MarshalJSON() ([]byte, error) { + type embed TreeDescribable + var marshaler = struct { + embed + }{ + embed: embed(*t), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, t.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (t *TreeDescribable) String() string { + if t == nil { + return "" + } + if len(t.rawJSON) > 0 { + if value, err := internal.StringifyJSON(t.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(t); err == nil { + return value + } + return fmt.Sprintf("%#v", t) +} + +var ( + treeIdentifiableFieldID = big.NewInt(1 << 0) +) + +type TreeIdentifiable struct { + // Unique tree identifier. + ID string `json:"id" url:"id"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (t *TreeIdentifiable) GetID() string { + if t == nil { + return "" + } + return t.ID +} + +func (t *TreeIdentifiable) GetExtraProperties() map[string]interface{} { + if t == nil { + return nil + } + return t.extraProperties +} + +func (t *TreeIdentifiable) require(field *big.Int) { + if t.explicitFields == nil { + t.explicitFields = big.NewInt(0) + } + t.explicitFields.Or(t.explicitFields, field) +} + +// SetID sets the ID field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeIdentifiable) SetID(id string) { + t.ID = id + t.require(treeIdentifiableFieldID) +} + +func (t *TreeIdentifiable) UnmarshalJSON(data []byte) error { + type unmarshaler TreeIdentifiable + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *t = TreeIdentifiable(value) + extraProperties, err := internal.ExtractExtraProperties(data, *t) + if err != nil { + return err + } + t.extraProperties = extraProperties + t.rawJSON = json.RawMessage(data) + return nil +} + +func (t *TreeIdentifiable) MarshalJSON() ([]byte, error) { + type embed TreeIdentifiable + var marshaler = struct { + embed + }{ + embed: embed(*t), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, t.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (t *TreeIdentifiable) String() string { + if t == nil { + return "" + } + if len(t.rawJSON) > 0 { + if value, err := internal.StringifyJSON(t.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(t); err == nil { + return value + } + return fmt.Sprintf("%#v", t) +} + +var ( + treeRecordFieldID = big.NewInt(1 << 0) + treeRecordFieldTreeName = big.NewInt(1 << 1) + treeRecordFieldTreeDescription = big.NewInt(1 << 2) + treeRecordFieldTreeSpecies = big.NewInt(1 << 3) + treeRecordFieldHeightInFeet = big.NewInt(1 << 4) + treeRecordFieldPlantedDate = big.NewInt(1 << 5) +) + +type TreeRecord struct { + // Unique tree identifier. + ID string `json:"id" url:"id"` + // Display name of the tree. + TreeName *string `json:"treeName,omitempty" url:"treeName,omitempty"` + // A description of the tree. + TreeDescription *string `json:"treeDescription,omitempty" url:"treeDescription,omitempty"` + // The species of tree. + TreeSpecies *string `json:"treeSpecies,omitempty" url:"treeSpecies,omitempty"` + // Height of the tree in feet. + HeightInFeet *float64 `json:"heightInFeet,omitempty" url:"heightInFeet,omitempty"` + // Date the tree was planted. + PlantedDate *time.Time `json:"plantedDate,omitempty" url:"plantedDate,omitempty" format:"date"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (t *TreeRecord) GetID() string { + if t == nil { + return "" + } + return t.ID +} + +func (t *TreeRecord) GetTreeName() *string { + if t == nil { + return nil + } + return t.TreeName +} + +func (t *TreeRecord) GetTreeDescription() *string { + if t == nil { + return nil + } + return t.TreeDescription +} + +func (t *TreeRecord) GetTreeSpecies() *string { + if t == nil { + return nil + } + return t.TreeSpecies +} + +func (t *TreeRecord) GetHeightInFeet() *float64 { + if t == nil { + return nil + } + return t.HeightInFeet +} + +func (t *TreeRecord) GetPlantedDate() *time.Time { + if t == nil { + return nil + } + return t.PlantedDate +} + +func (t *TreeRecord) GetExtraProperties() map[string]interface{} { + if t == nil { + return nil + } + return t.extraProperties +} + +func (t *TreeRecord) require(field *big.Int) { + if t.explicitFields == nil { + t.explicitFields = big.NewInt(0) + } + t.explicitFields.Or(t.explicitFields, field) +} + +// SetID sets the ID field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeRecord) SetID(id string) { + t.ID = id + t.require(treeRecordFieldID) +} + +// SetTreeName sets the TreeName field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeRecord) SetTreeName(treeName *string) { + t.TreeName = treeName + t.require(treeRecordFieldTreeName) +} + +// SetTreeDescription sets the TreeDescription field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeRecord) SetTreeDescription(treeDescription *string) { + t.TreeDescription = treeDescription + t.require(treeRecordFieldTreeDescription) +} + +// SetTreeSpecies sets the TreeSpecies field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeRecord) SetTreeSpecies(treeSpecies *string) { + t.TreeSpecies = treeSpecies + t.require(treeRecordFieldTreeSpecies) +} + +// SetHeightInFeet sets the HeightInFeet field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeRecord) SetHeightInFeet(heightInFeet *float64) { + t.HeightInFeet = heightInFeet + t.require(treeRecordFieldHeightInFeet) +} + +// SetPlantedDate sets the PlantedDate field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeRecord) SetPlantedDate(plantedDate *time.Time) { + t.PlantedDate = plantedDate + t.require(treeRecordFieldPlantedDate) +} + +func (t *TreeRecord) UnmarshalJSON(data []byte) error { + type embed TreeRecord + var unmarshaler = struct { + embed + PlantedDate *internal.Date `json:"plantedDate,omitempty"` + }{ + embed: embed(*t), + } + if err := json.Unmarshal(data, &unmarshaler); err != nil { + return err + } + *t = TreeRecord(unmarshaler.embed) + t.PlantedDate = unmarshaler.PlantedDate.TimePtr() + extraProperties, err := internal.ExtractExtraProperties(data, *t) + if err != nil { + return err + } + t.extraProperties = extraProperties + t.rawJSON = json.RawMessage(data) + return nil +} + +func (t *TreeRecord) MarshalJSON() ([]byte, error) { + type embed TreeRecord + var marshaler = struct { + embed + PlantedDate *internal.Date `json:"plantedDate,omitempty"` + }{ + embed: embed(*t), + PlantedDate: internal.NewOptionalDate(t.PlantedDate), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, t.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (t *TreeRecord) String() string { + if t == nil { + return "" + } + if len(t.rawJSON) > 0 { + if value, err := internal.StringifyJSON(t.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(t); err == nil { + return value + } + return fmt.Sprintf("%#v", t) +} + var ( userFieldID = big.NewInt(1 << 0) userFieldEmail = big.NewInt(1 << 1) diff --git a/seed/go-sdk/allof/types_test.go b/seed/go-sdk/allof/types_test.go index ec49e19a47b4..f447ac2089c7 100644 --- a/seed/go-sdk/allof/types_test.go +++ b/seed/go-sdk/allof/types_test.go @@ -10,6 +10,129 @@ import ( time "time" ) +func TestSettersPlantPost(t *testing.T) { + t.Run("SetSunExposure", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValueSunExposure PlantPostSunExposure + obj.SetSunExposure(fernTestValueSunExposure) + assert.Equal(t, fernTestValueSunExposure, obj.SunExposure) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetPlantedAt", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValuePlantedAt *time.Time + obj.SetPlantedAt(fernTestValuePlantedAt) + assert.Equal(t, fernTestValuePlantedAt, obj.PlantedAt) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetSoilType", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValueSoilType *string + obj.SetSoilType(fernTestValueSoilType) + assert.Equal(t, fernTestValueSoilType, obj.SoilType) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestSettersMarkExplicitPlantPost(t *testing.T) { + t.Run("SetSunExposure_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValueSunExposure PlantPostSunExposure + + // Act + obj.SetSunExposure(fernTestValueSunExposure) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetPlantedAt_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValuePlantedAt *time.Time + + // Act + obj.SetPlantedAt(fernTestValuePlantedAt) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetSoilType_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValueSoilType *string + + // Act + obj.SetSoilType(fernTestValueSoilType) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + func TestSettersRuleCreateRequest(t *testing.T) { t.Run("SetName", func(t *testing.T) { obj := &RuleCreateRequest{} @@ -2020,412 +2143,196 @@ func TestSettersMarkExplicitPagingCursors(t *testing.T) { } -func TestSettersRuleResponse(t *testing.T) { - t.Run("SetCreatedBy", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueCreatedBy *string - obj.SetCreatedBy(fernTestValueCreatedBy) - assert.Equal(t, fernTestValueCreatedBy, obj.CreatedBy) - assert.NotNil(t, obj.explicitFields) - }) - - t.Run("SetCreatedDateTime", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueCreatedDateTime *time.Time - obj.SetCreatedDateTime(fernTestValueCreatedDateTime) - assert.Equal(t, fernTestValueCreatedDateTime, obj.CreatedDateTime) - assert.NotNil(t, obj.explicitFields) - }) - - t.Run("SetModifiedBy", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueModifiedBy *string - obj.SetModifiedBy(fernTestValueModifiedBy) - assert.Equal(t, fernTestValueModifiedBy, obj.ModifiedBy) - assert.NotNil(t, obj.explicitFields) - }) - - t.Run("SetModifiedDateTime", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueModifiedDateTime *time.Time - obj.SetModifiedDateTime(fernTestValueModifiedDateTime) - assert.Equal(t, fernTestValueModifiedDateTime, obj.ModifiedDateTime) +func TestSettersPlantBase(t *testing.T) { + t.Run("SetSpecies", func(t *testing.T) { + obj := &PlantBase{} + var fernTestValueSpecies string + obj.SetSpecies(fernTestValueSpecies) + assert.Equal(t, fernTestValueSpecies, obj.Species) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetID", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueID string - obj.SetID(fernTestValueID) - assert.Equal(t, fernTestValueID, obj.ID) + t.Run("SetFamily", func(t *testing.T) { + obj := &PlantBase{} + var fernTestValueFamily string + obj.SetFamily(fernTestValueFamily) + assert.Equal(t, fernTestValueFamily, obj.Family) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetName", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueName string - obj.SetName(fernTestValueName) - assert.Equal(t, fernTestValueName, obj.Name) + t.Run("SetGenus", func(t *testing.T) { + obj := &PlantBase{} + var fernTestValueGenus string + obj.SetGenus(fernTestValueGenus) + assert.Equal(t, fernTestValueGenus, obj.Genus) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetStatus", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueStatus RuleResponseStatus - obj.SetStatus(fernTestValueStatus) - assert.Equal(t, fernTestValueStatus, obj.Status) + t.Run("SetCommonName", func(t *testing.T) { + obj := &PlantBase{} + var fernTestValueCommonName *string + obj.SetCommonName(fernTestValueCommonName) + assert.Equal(t, fernTestValueCommonName, obj.CommonName) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetExecutionContext", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueExecutionContext *RuleExecutionContext - obj.SetExecutionContext(fernTestValueExecutionContext) - assert.Equal(t, fernTestValueExecutionContext, obj.ExecutionContext) + t.Run("SetWateringFrequency", func(t *testing.T) { + obj := &PlantBase{} + var fernTestValueWateringFrequency *PlantBaseWateringFrequency + obj.SetWateringFrequency(fernTestValueWateringFrequency) + assert.Equal(t, fernTestValueWateringFrequency, obj.WateringFrequency) assert.NotNil(t, obj.explicitFields) }) } -func TestGettersRuleResponse(t *testing.T) { - t.Run("GetCreatedBy", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var expected *string - obj.CreatedBy = expected - - // Act & Assert - assert.Equal(t, expected, obj.GetCreatedBy(), "getter should return the property value") - }) - - t.Run("GetCreatedBy_NilValue", func(t *testing.T) { +func TestGettersPlantBase(t *testing.T) { + t.Run("GetSpecies", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - obj.CreatedBy = nil + obj := &PlantBase{} + var expected string + obj.Species = expected // Act & Assert - assert.Nil(t, obj.GetCreatedBy(), "getter should return nil when property is nil") + assert.Equal(t, expected, obj.GetSpecies(), "getter should return the property value") }) - t.Run("GetCreatedBy_NilReceiver", func(t *testing.T) { + t.Run("GetSpecies_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *PlantBase // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetCreatedBy() // Should return zero value - }) - - t.Run("GetCreatedDateTime", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var expected *time.Time - obj.CreatedDateTime = expected - - // Act & Assert - assert.Equal(t, expected, obj.GetCreatedDateTime(), "getter should return the property value") + _ = obj.GetSpecies() // Should return zero value }) - t.Run("GetCreatedDateTime_NilValue", func(t *testing.T) { + t.Run("GetFamily", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - obj.CreatedDateTime = nil + obj := &PlantBase{} + var expected string + obj.Family = expected // Act & Assert - assert.Nil(t, obj.GetCreatedDateTime(), "getter should return nil when property is nil") + assert.Equal(t, expected, obj.GetFamily(), "getter should return the property value") }) - t.Run("GetCreatedDateTime_NilReceiver", func(t *testing.T) { + t.Run("GetFamily_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *PlantBase // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetCreatedDateTime() // Should return zero value - }) - - t.Run("GetModifiedBy", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var expected *string - obj.ModifiedBy = expected - - // Act & Assert - assert.Equal(t, expected, obj.GetModifiedBy(), "getter should return the property value") + _ = obj.GetFamily() // Should return zero value }) - t.Run("GetModifiedBy_NilValue", func(t *testing.T) { + t.Run("GetGenus", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - obj.ModifiedBy = nil + obj := &PlantBase{} + var expected string + obj.Genus = expected // Act & Assert - assert.Nil(t, obj.GetModifiedBy(), "getter should return nil when property is nil") + assert.Equal(t, expected, obj.GetGenus(), "getter should return the property value") }) - t.Run("GetModifiedBy_NilReceiver", func(t *testing.T) { + t.Run("GetGenus_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *PlantBase // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetModifiedBy() // Should return zero value + _ = obj.GetGenus() // Should return zero value }) - t.Run("GetModifiedDateTime", func(t *testing.T) { + t.Run("GetCommonName", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var expected *time.Time - obj.ModifiedDateTime = expected + obj := &PlantBase{} + var expected *string + obj.CommonName = expected // Act & Assert - assert.Equal(t, expected, obj.GetModifiedDateTime(), "getter should return the property value") + assert.Equal(t, expected, obj.GetCommonName(), "getter should return the property value") }) - t.Run("GetModifiedDateTime_NilValue", func(t *testing.T) { + t.Run("GetCommonName_NilValue", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - obj.ModifiedDateTime = nil + obj := &PlantBase{} + obj.CommonName = nil // Act & Assert - assert.Nil(t, obj.GetModifiedDateTime(), "getter should return nil when property is nil") + assert.Nil(t, obj.GetCommonName(), "getter should return nil when property is nil") }) - t.Run("GetModifiedDateTime_NilReceiver", func(t *testing.T) { + t.Run("GetCommonName_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *PlantBase // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetModifiedDateTime() // Should return zero value + _ = obj.GetCommonName() // Should return zero value }) - t.Run("GetID", func(t *testing.T) { + t.Run("GetWateringFrequency", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var expected string - obj.ID = expected + obj := &PlantBase{} + var expected *PlantBaseWateringFrequency + obj.WateringFrequency = expected // Act & Assert - assert.Equal(t, expected, obj.GetID(), "getter should return the property value") - }) - - t.Run("GetID_NilReceiver", func(t *testing.T) { - t.Parallel() - var obj *RuleResponse - // Should not panic - getters should handle nil receiver gracefully - defer func() { - if r := recover(); r != nil { - t.Errorf("Getter panicked on nil receiver: %v", r) - } - }() - _ = obj.GetID() // Should return zero value + assert.Equal(t, expected, obj.GetWateringFrequency(), "getter should return the property value") }) - t.Run("GetName", func(t *testing.T) { + t.Run("GetWateringFrequency_NilValue", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var expected string - obj.Name = expected + obj := &PlantBase{} + obj.WateringFrequency = nil // Act & Assert - assert.Equal(t, expected, obj.GetName(), "getter should return the property value") + assert.Nil(t, obj.GetWateringFrequency(), "getter should return nil when property is nil") }) - t.Run("GetName_NilReceiver", func(t *testing.T) { + t.Run("GetWateringFrequency_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *PlantBase // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetName() // Should return zero value + _ = obj.GetWateringFrequency() // Should return zero value }) - t.Run("GetStatus", func(t *testing.T) { +} + +func TestSettersMarkExplicitPlantBase(t *testing.T) { + t.Run("SetSpecies_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var expected RuleResponseStatus - obj.Status = expected + obj := &PlantBase{} + var fernTestValueSpecies string - // Act & Assert - assert.Equal(t, expected, obj.GetStatus(), "getter should return the property value") - }) - - t.Run("GetStatus_NilReceiver", func(t *testing.T) { - t.Parallel() - var obj *RuleResponse - // Should not panic - getters should handle nil receiver gracefully - defer func() { - if r := recover(); r != nil { - t.Errorf("Getter panicked on nil receiver: %v", r) - } - }() - _ = obj.GetStatus() // Should return zero value - }) - - t.Run("GetExecutionContext", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var expected *RuleExecutionContext - obj.ExecutionContext = expected - - // Act & Assert - assert.Equal(t, expected, obj.GetExecutionContext(), "getter should return the property value") - }) - - t.Run("GetExecutionContext_NilValue", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - obj.ExecutionContext = nil - - // Act & Assert - assert.Nil(t, obj.GetExecutionContext(), "getter should return nil when property is nil") - }) - - t.Run("GetExecutionContext_NilReceiver", func(t *testing.T) { - t.Parallel() - var obj *RuleResponse - // Should not panic - getters should handle nil receiver gracefully - defer func() { - if r := recover(); r != nil { - t.Errorf("Getter panicked on nil receiver: %v", r) - } - }() - _ = obj.GetExecutionContext() // Should return zero value - }) - -} - -func TestSettersMarkExplicitRuleResponse(t *testing.T) { - t.Run("SetCreatedBy_MarksExplicit", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var fernTestValueCreatedBy *string - - // Act - obj.SetCreatedBy(fernTestValueCreatedBy) - - // Assert - object with explicitly set field can be marshaled/unmarshaled - bytes, err := json.Marshal(obj) - require.NoError(t, err, "marshaling should succeed for test setup") - - // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value - // Detect if marshaled JSON is an object or primitive to use correct unmarshal target - if len(bytes) > 0 && bytes[0] == '{' { - // JSON object - unmarshal into map - var unmarshaled map[string]interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } else { - // JSON primitive (string, number, boolean, null) - unmarshal into interface{} - var unmarshaled interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } - - // Note: This does not explicitly assert the presence of a specific JSON field - // It verifies that setting a field via setter allows successful JSON round-trip - }) - - t.Run("SetCreatedDateTime_MarksExplicit", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var fernTestValueCreatedDateTime *time.Time - - // Act - obj.SetCreatedDateTime(fernTestValueCreatedDateTime) - - // Assert - object with explicitly set field can be marshaled/unmarshaled - bytes, err := json.Marshal(obj) - require.NoError(t, err, "marshaling should succeed for test setup") - - // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value - // Detect if marshaled JSON is an object or primitive to use correct unmarshal target - if len(bytes) > 0 && bytes[0] == '{' { - // JSON object - unmarshal into map - var unmarshaled map[string]interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } else { - // JSON primitive (string, number, boolean, null) - unmarshal into interface{} - var unmarshaled interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } - - // Note: This does not explicitly assert the presence of a specific JSON field - // It verifies that setting a field via setter allows successful JSON round-trip - }) - - t.Run("SetModifiedBy_MarksExplicit", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var fernTestValueModifiedBy *string - - // Act - obj.SetModifiedBy(fernTestValueModifiedBy) - - // Assert - object with explicitly set field can be marshaled/unmarshaled - bytes, err := json.Marshal(obj) - require.NoError(t, err, "marshaling should succeed for test setup") - - // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value - // Detect if marshaled JSON is an object or primitive to use correct unmarshal target - if len(bytes) > 0 && bytes[0] == '{' { - // JSON object - unmarshal into map - var unmarshaled map[string]interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } else { - // JSON primitive (string, number, boolean, null) - unmarshal into interface{} - var unmarshaled interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } - - // Note: This does not explicitly assert the presence of a specific JSON field - // It verifies that setting a field via setter allows successful JSON round-trip - }) - - t.Run("SetModifiedDateTime_MarksExplicit", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var fernTestValueModifiedDateTime *time.Time - - // Act - obj.SetModifiedDateTime(fernTestValueModifiedDateTime) + // Act + obj.SetSpecies(fernTestValueSpecies) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2449,14 +2356,14 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetID_MarksExplicit", func(t *testing.T) { + t.Run("SetFamily_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueID string + obj := &PlantBase{} + var fernTestValueFamily string // Act - obj.SetID(fernTestValueID) + obj.SetFamily(fernTestValueFamily) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2480,14 +2387,14 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetName_MarksExplicit", func(t *testing.T) { + t.Run("SetGenus_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueName string + obj := &PlantBase{} + var fernTestValueGenus string // Act - obj.SetName(fernTestValueName) + obj.SetGenus(fernTestValueGenus) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2511,14 +2418,14 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetStatus_MarksExplicit", func(t *testing.T) { + t.Run("SetCommonName_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueStatus RuleResponseStatus + obj := &PlantBase{} + var fernTestValueCommonName *string // Act - obj.SetStatus(fernTestValueStatus) + obj.SetCommonName(fernTestValueCommonName) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2542,14 +2449,14 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetExecutionContext_MarksExplicit", func(t *testing.T) { + t.Run("SetWateringFrequency_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueExecutionContext *RuleExecutionContext + obj := &PlantBase{} + var fernTestValueWateringFrequency *PlantBaseWateringFrequency // Act - obj.SetExecutionContext(fernTestValueExecutionContext) + obj.SetWateringFrequency(fernTestValueWateringFrequency) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2575,124 +2482,114 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { } -func TestSettersRuleType(t *testing.T) { - t.Run("SetID", func(t *testing.T) { - obj := &RuleType{} - var fernTestValueID string - obj.SetID(fernTestValueID) - assert.Equal(t, fernTestValueID, obj.ID) +func TestSettersPlantStrict(t *testing.T) { + t.Run("SetSpecies", func(t *testing.T) { + obj := &PlantStrict{} + var fernTestValueSpecies string + obj.SetSpecies(fernTestValueSpecies) + assert.Equal(t, fernTestValueSpecies, obj.Species) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetName", func(t *testing.T) { - obj := &RuleType{} - var fernTestValueName string - obj.SetName(fernTestValueName) - assert.Equal(t, fernTestValueName, obj.Name) + t.Run("SetFamily", func(t *testing.T) { + obj := &PlantStrict{} + var fernTestValueFamily string + obj.SetFamily(fernTestValueFamily) + assert.Equal(t, fernTestValueFamily, obj.Family) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetDescription", func(t *testing.T) { - obj := &RuleType{} - var fernTestValueDescription *string - obj.SetDescription(fernTestValueDescription) - assert.Equal(t, fernTestValueDescription, obj.Description) + t.Run("SetGenus", func(t *testing.T) { + obj := &PlantStrict{} + var fernTestValueGenus string + obj.SetGenus(fernTestValueGenus) + assert.Equal(t, fernTestValueGenus, obj.Genus) assert.NotNil(t, obj.explicitFields) }) } -func TestGettersRuleType(t *testing.T) { - t.Run("GetID", func(t *testing.T) { +func TestGettersPlantStrict(t *testing.T) { + t.Run("GetSpecies", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} + obj := &PlantStrict{} var expected string - obj.ID = expected + obj.Species = expected // Act & Assert - assert.Equal(t, expected, obj.GetID(), "getter should return the property value") + assert.Equal(t, expected, obj.GetSpecies(), "getter should return the property value") }) - t.Run("GetID_NilReceiver", func(t *testing.T) { + t.Run("GetSpecies_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleType + var obj *PlantStrict // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetID() // Should return zero value + _ = obj.GetSpecies() // Should return zero value }) - t.Run("GetName", func(t *testing.T) { + t.Run("GetFamily", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} + obj := &PlantStrict{} var expected string - obj.Name = expected + obj.Family = expected // Act & Assert - assert.Equal(t, expected, obj.GetName(), "getter should return the property value") + assert.Equal(t, expected, obj.GetFamily(), "getter should return the property value") }) - t.Run("GetName_NilReceiver", func(t *testing.T) { + t.Run("GetFamily_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleType + var obj *PlantStrict // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetName() // Should return zero value - }) - - t.Run("GetDescription", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleType{} - var expected *string - obj.Description = expected - - // Act & Assert - assert.Equal(t, expected, obj.GetDescription(), "getter should return the property value") + _ = obj.GetFamily() // Should return zero value }) - t.Run("GetDescription_NilValue", func(t *testing.T) { + t.Run("GetGenus", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} - obj.Description = nil + obj := &PlantStrict{} + var expected string + obj.Genus = expected // Act & Assert - assert.Nil(t, obj.GetDescription(), "getter should return nil when property is nil") + assert.Equal(t, expected, obj.GetGenus(), "getter should return the property value") }) - t.Run("GetDescription_NilReceiver", func(t *testing.T) { + t.Run("GetGenus_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleType + var obj *PlantStrict // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetDescription() // Should return zero value + _ = obj.GetGenus() // Should return zero value }) } -func TestSettersMarkExplicitRuleType(t *testing.T) { - t.Run("SetID_MarksExplicit", func(t *testing.T) { +func TestSettersMarkExplicitPlantStrict(t *testing.T) { + t.Run("SetSpecies_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} - var fernTestValueID string + obj := &PlantStrict{} + var fernTestValueSpecies string // Act - obj.SetID(fernTestValueID) + obj.SetSpecies(fernTestValueSpecies) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2716,14 +2613,14 @@ func TestSettersMarkExplicitRuleType(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetName_MarksExplicit", func(t *testing.T) { + t.Run("SetFamily_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} - var fernTestValueName string + obj := &PlantStrict{} + var fernTestValueFamily string // Act - obj.SetName(fernTestValueName) + obj.SetFamily(fernTestValueFamily) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2747,14 +2644,14 @@ func TestSettersMarkExplicitRuleType(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetDescription_MarksExplicit", func(t *testing.T) { + t.Run("SetGenus_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} - var fernTestValueDescription *string + obj := &PlantStrict{} + var fernTestValueGenus string // Act - obj.SetDescription(fernTestValueDescription) + obj.SetGenus(fernTestValueGenus) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2780,103 +2677,1877 @@ func TestSettersMarkExplicitRuleType(t *testing.T) { } -func TestSettersRuleTypeSearchResponse(t *testing.T) { - t.Run("SetResults", func(t *testing.T) { - obj := &RuleTypeSearchResponse{} - var fernTestValueResults []*RuleType - obj.SetResults(fernTestValueResults) - assert.Equal(t, fernTestValueResults, obj.Results) +func TestSettersRuleResponse(t *testing.T) { + t.Run("SetCreatedBy", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueCreatedBy *string + obj.SetCreatedBy(fernTestValueCreatedBy) + assert.Equal(t, fernTestValueCreatedBy, obj.CreatedBy) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetPaging", func(t *testing.T) { - obj := &RuleTypeSearchResponse{} - var fernTestValuePaging *PagingCursors - obj.SetPaging(fernTestValuePaging) - assert.Equal(t, fernTestValuePaging, obj.Paging) + t.Run("SetCreatedDateTime", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueCreatedDateTime *time.Time + obj.SetCreatedDateTime(fernTestValueCreatedDateTime) + assert.Equal(t, fernTestValueCreatedDateTime, obj.CreatedDateTime) assert.NotNil(t, obj.explicitFields) }) -} + t.Run("SetModifiedBy", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueModifiedBy *string + obj.SetModifiedBy(fernTestValueModifiedBy) + assert.Equal(t, fernTestValueModifiedBy, obj.ModifiedBy) + assert.NotNil(t, obj.explicitFields) + }) -func TestGettersRuleTypeSearchResponse(t *testing.T) { - t.Run("GetResults", func(t *testing.T) { - t.Parallel() + t.Run("SetModifiedDateTime", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueModifiedDateTime *time.Time + obj.SetModifiedDateTime(fernTestValueModifiedDateTime) + assert.Equal(t, fernTestValueModifiedDateTime, obj.ModifiedDateTime) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetID", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueID string + obj.SetID(fernTestValueID) + assert.Equal(t, fernTestValueID, obj.ID) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetName", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueName string + obj.SetName(fernTestValueName) + assert.Equal(t, fernTestValueName, obj.Name) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetStatus", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueStatus RuleResponseStatus + obj.SetStatus(fernTestValueStatus) + assert.Equal(t, fernTestValueStatus, obj.Status) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetExecutionContext", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueExecutionContext *RuleExecutionContext + obj.SetExecutionContext(fernTestValueExecutionContext) + assert.Equal(t, fernTestValueExecutionContext, obj.ExecutionContext) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersRuleResponse(t *testing.T) { + t.Run("GetCreatedBy", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected *string + obj.CreatedBy = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetCreatedBy(), "getter should return the property value") + }) + + t.Run("GetCreatedBy_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + obj.CreatedBy = nil + + // Act & Assert + assert.Nil(t, obj.GetCreatedBy(), "getter should return nil when property is nil") + }) + + t.Run("GetCreatedBy_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetCreatedBy() // Should return zero value + }) + + t.Run("GetCreatedDateTime", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected *time.Time + obj.CreatedDateTime = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetCreatedDateTime(), "getter should return the property value") + }) + + t.Run("GetCreatedDateTime_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + obj.CreatedDateTime = nil + + // Act & Assert + assert.Nil(t, obj.GetCreatedDateTime(), "getter should return nil when property is nil") + }) + + t.Run("GetCreatedDateTime_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetCreatedDateTime() // Should return zero value + }) + + t.Run("GetModifiedBy", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected *string + obj.ModifiedBy = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetModifiedBy(), "getter should return the property value") + }) + + t.Run("GetModifiedBy_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + obj.ModifiedBy = nil + + // Act & Assert + assert.Nil(t, obj.GetModifiedBy(), "getter should return nil when property is nil") + }) + + t.Run("GetModifiedBy_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetModifiedBy() // Should return zero value + }) + + t.Run("GetModifiedDateTime", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected *time.Time + obj.ModifiedDateTime = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetModifiedDateTime(), "getter should return the property value") + }) + + t.Run("GetModifiedDateTime_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + obj.ModifiedDateTime = nil + + // Act & Assert + assert.Nil(t, obj.GetModifiedDateTime(), "getter should return nil when property is nil") + }) + + t.Run("GetModifiedDateTime_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetModifiedDateTime() // Should return zero value + }) + + t.Run("GetID", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected string + obj.ID = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetID(), "getter should return the property value") + }) + + t.Run("GetID_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetID() // Should return zero value + }) + + t.Run("GetName", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected string + obj.Name = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetName(), "getter should return the property value") + }) + + t.Run("GetName_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetName() // Should return zero value + }) + + t.Run("GetStatus", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected RuleResponseStatus + obj.Status = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetStatus(), "getter should return the property value") + }) + + t.Run("GetStatus_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetStatus() // Should return zero value + }) + + t.Run("GetExecutionContext", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected *RuleExecutionContext + obj.ExecutionContext = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetExecutionContext(), "getter should return the property value") + }) + + t.Run("GetExecutionContext_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + obj.ExecutionContext = nil + + // Act & Assert + assert.Nil(t, obj.GetExecutionContext(), "getter should return nil when property is nil") + }) + + t.Run("GetExecutionContext_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetExecutionContext() // Should return zero value + }) + +} + +func TestSettersMarkExplicitRuleResponse(t *testing.T) { + t.Run("SetCreatedBy_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueCreatedBy *string + + // Act + obj.SetCreatedBy(fernTestValueCreatedBy) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetCreatedDateTime_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueCreatedDateTime *time.Time + + // Act + obj.SetCreatedDateTime(fernTestValueCreatedDateTime) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetModifiedBy_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueModifiedBy *string + + // Act + obj.SetModifiedBy(fernTestValueModifiedBy) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetModifiedDateTime_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueModifiedDateTime *time.Time + + // Act + obj.SetModifiedDateTime(fernTestValueModifiedDateTime) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetID_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueID string + + // Act + obj.SetID(fernTestValueID) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetName_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueName string + + // Act + obj.SetName(fernTestValueName) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetStatus_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueStatus RuleResponseStatus + + // Act + obj.SetStatus(fernTestValueStatus) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetExecutionContext_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueExecutionContext *RuleExecutionContext + + // Act + obj.SetExecutionContext(fernTestValueExecutionContext) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersRuleType(t *testing.T) { + t.Run("SetID", func(t *testing.T) { + obj := &RuleType{} + var fernTestValueID string + obj.SetID(fernTestValueID) + assert.Equal(t, fernTestValueID, obj.ID) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetName", func(t *testing.T) { + obj := &RuleType{} + var fernTestValueName string + obj.SetName(fernTestValueName) + assert.Equal(t, fernTestValueName, obj.Name) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetDescription", func(t *testing.T) { + obj := &RuleType{} + var fernTestValueDescription *string + obj.SetDescription(fernTestValueDescription) + assert.Equal(t, fernTestValueDescription, obj.Description) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersRuleType(t *testing.T) { + t.Run("GetID", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var expected string + obj.ID = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetID(), "getter should return the property value") + }) + + t.Run("GetID_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleType + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetID() // Should return zero value + }) + + t.Run("GetName", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var expected string + obj.Name = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetName(), "getter should return the property value") + }) + + t.Run("GetName_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleType + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetName() // Should return zero value + }) + + t.Run("GetDescription", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var expected *string + obj.Description = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetDescription(), "getter should return the property value") + }) + + t.Run("GetDescription_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + obj.Description = nil + + // Act & Assert + assert.Nil(t, obj.GetDescription(), "getter should return nil when property is nil") + }) + + t.Run("GetDescription_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleType + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetDescription() // Should return zero value + }) + +} + +func TestSettersMarkExplicitRuleType(t *testing.T) { + t.Run("SetID_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var fernTestValueID string + + // Act + obj.SetID(fernTestValueID) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetName_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var fernTestValueName string + + // Act + obj.SetName(fernTestValueName) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetDescription_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var fernTestValueDescription *string + + // Act + obj.SetDescription(fernTestValueDescription) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersRuleTypeSearchResponse(t *testing.T) { + t.Run("SetResults", func(t *testing.T) { + obj := &RuleTypeSearchResponse{} + var fernTestValueResults []*RuleType + obj.SetResults(fernTestValueResults) + assert.Equal(t, fernTestValueResults, obj.Results) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetPaging", func(t *testing.T) { + obj := &RuleTypeSearchResponse{} + var fernTestValuePaging *PagingCursors + obj.SetPaging(fernTestValuePaging) + assert.Equal(t, fernTestValuePaging, obj.Paging) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersRuleTypeSearchResponse(t *testing.T) { + t.Run("GetResults", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + var expected []*RuleType + obj.Results = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetResults(), "getter should return the property value") + }) + + t.Run("GetResults_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + obj.Results = nil + + // Act & Assert + assert.Nil(t, obj.GetResults(), "getter should return nil when property is nil") + }) + + t.Run("GetResults_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleTypeSearchResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetResults() // Should return zero value + }) + + t.Run("GetPaging", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + var expected *PagingCursors + obj.Paging = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetPaging(), "getter should return the property value") + }) + + t.Run("GetPaging_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + obj.Paging = nil + + // Act & Assert + assert.Nil(t, obj.GetPaging(), "getter should return nil when property is nil") + }) + + t.Run("GetPaging_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleTypeSearchResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetPaging() // Should return zero value + }) + +} + +func TestSettersMarkExplicitRuleTypeSearchResponse(t *testing.T) { + t.Run("SetResults_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + var fernTestValueResults []*RuleType + + // Act + obj.SetResults(fernTestValueResults) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetPaging_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + var fernTestValuePaging *PagingCursors + + // Act + obj.SetPaging(fernTestValuePaging) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersTreeBase(t *testing.T) { + t.Run("SetID", func(t *testing.T) { + obj := &TreeBase{} + var fernTestValueID string + obj.SetID(fernTestValueID) + assert.Equal(t, fernTestValueID, obj.ID) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeName", func(t *testing.T) { + obj := &TreeBase{} + var fernTestValueTreeName *string + obj.SetTreeName(fernTestValueTreeName) + assert.Equal(t, fernTestValueTreeName, obj.TreeName) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeDescription", func(t *testing.T) { + obj := &TreeBase{} + var fernTestValueTreeDescription *string + obj.SetTreeDescription(fernTestValueTreeDescription) + assert.Equal(t, fernTestValueTreeDescription, obj.TreeDescription) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeSpecies", func(t *testing.T) { + obj := &TreeBase{} + var fernTestValueTreeSpecies *string + obj.SetTreeSpecies(fernTestValueTreeSpecies) + assert.Equal(t, fernTestValueTreeSpecies, obj.TreeSpecies) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetHeightInFeet", func(t *testing.T) { + obj := &TreeBase{} + var fernTestValueHeightInFeet *float64 + obj.SetHeightInFeet(fernTestValueHeightInFeet) + assert.Equal(t, fernTestValueHeightInFeet, obj.HeightInFeet) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersTreeBase(t *testing.T) { + t.Run("GetID", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var expected string + obj.ID = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetID(), "getter should return the property value") + }) + + t.Run("GetID_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetID() // Should return zero value + }) + + t.Run("GetTreeName", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var expected *string + obj.TreeName = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeName(), "getter should return the property value") + }) + + t.Run("GetTreeName_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + obj.TreeName = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeName(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeName_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeName() // Should return zero value + }) + + t.Run("GetTreeDescription", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var expected *string + obj.TreeDescription = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeDescription(), "getter should return the property value") + }) + + t.Run("GetTreeDescription_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + obj.TreeDescription = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeDescription(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeDescription_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeDescription() // Should return zero value + }) + + t.Run("GetTreeSpecies", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var expected *string + obj.TreeSpecies = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeSpecies(), "getter should return the property value") + }) + + t.Run("GetTreeSpecies_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + obj.TreeSpecies = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeSpecies(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeSpecies_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeSpecies() // Should return zero value + }) + + t.Run("GetHeightInFeet", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var expected *float64 + obj.HeightInFeet = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetHeightInFeet(), "getter should return the property value") + }) + + t.Run("GetHeightInFeet_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + obj.HeightInFeet = nil + + // Act & Assert + assert.Nil(t, obj.GetHeightInFeet(), "getter should return nil when property is nil") + }) + + t.Run("GetHeightInFeet_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetHeightInFeet() // Should return zero value + }) + +} + +func TestSettersMarkExplicitTreeBase(t *testing.T) { + t.Run("SetID_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var fernTestValueID string + + // Act + obj.SetID(fernTestValueID) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetTreeName_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var fernTestValueTreeName *string + + // Act + obj.SetTreeName(fernTestValueTreeName) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetTreeDescription_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var fernTestValueTreeDescription *string + + // Act + obj.SetTreeDescription(fernTestValueTreeDescription) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetTreeSpecies_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var fernTestValueTreeSpecies *string + + // Act + obj.SetTreeSpecies(fernTestValueTreeSpecies) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetHeightInFeet_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var fernTestValueHeightInFeet *float64 + + // Act + obj.SetHeightInFeet(fernTestValueHeightInFeet) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersTreeDescribable(t *testing.T) { + t.Run("SetTreeName", func(t *testing.T) { + obj := &TreeDescribable{} + var fernTestValueTreeName *string + obj.SetTreeName(fernTestValueTreeName) + assert.Equal(t, fernTestValueTreeName, obj.TreeName) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeDescription", func(t *testing.T) { + obj := &TreeDescribable{} + var fernTestValueTreeDescription *string + obj.SetTreeDescription(fernTestValueTreeDescription) + assert.Equal(t, fernTestValueTreeDescription, obj.TreeDescription) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersTreeDescribable(t *testing.T) { + t.Run("GetTreeName", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + var expected *string + obj.TreeName = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeName(), "getter should return the property value") + }) + + t.Run("GetTreeName_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + obj.TreeName = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeName(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeName_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeDescribable + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeName() // Should return zero value + }) + + t.Run("GetTreeDescription", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + var expected *string + obj.TreeDescription = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeDescription(), "getter should return the property value") + }) + + t.Run("GetTreeDescription_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + obj.TreeDescription = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeDescription(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeDescription_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeDescribable + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeDescription() // Should return zero value + }) + +} + +func TestSettersMarkExplicitTreeDescribable(t *testing.T) { + t.Run("SetTreeName_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + var fernTestValueTreeName *string + + // Act + obj.SetTreeName(fernTestValueTreeName) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetTreeDescription_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + var fernTestValueTreeDescription *string + + // Act + obj.SetTreeDescription(fernTestValueTreeDescription) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersTreeIdentifiable(t *testing.T) { + t.Run("SetID", func(t *testing.T) { + obj := &TreeIdentifiable{} + var fernTestValueID string + obj.SetID(fernTestValueID) + assert.Equal(t, fernTestValueID, obj.ID) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersTreeIdentifiable(t *testing.T) { + t.Run("GetID", func(t *testing.T) { + t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} - var expected []*RuleType - obj.Results = expected + obj := &TreeIdentifiable{} + var expected string + obj.ID = expected // Act & Assert - assert.Equal(t, expected, obj.GetResults(), "getter should return the property value") + assert.Equal(t, expected, obj.GetID(), "getter should return the property value") }) - t.Run("GetResults_NilValue", func(t *testing.T) { + t.Run("GetID_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeIdentifiable + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetID() // Should return zero value + }) + +} + +func TestSettersMarkExplicitTreeIdentifiable(t *testing.T) { + t.Run("SetID_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} - obj.Results = nil + obj := &TreeIdentifiable{} + var fernTestValueID string + + // Act + obj.SetID(fernTestValueID) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersTreeRecord(t *testing.T) { + t.Run("SetID", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValueID string + obj.SetID(fernTestValueID) + assert.Equal(t, fernTestValueID, obj.ID) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeName", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValueTreeName *string + obj.SetTreeName(fernTestValueTreeName) + assert.Equal(t, fernTestValueTreeName, obj.TreeName) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeDescription", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValueTreeDescription *string + obj.SetTreeDescription(fernTestValueTreeDescription) + assert.Equal(t, fernTestValueTreeDescription, obj.TreeDescription) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeSpecies", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValueTreeSpecies *string + obj.SetTreeSpecies(fernTestValueTreeSpecies) + assert.Equal(t, fernTestValueTreeSpecies, obj.TreeSpecies) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetHeightInFeet", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValueHeightInFeet *float64 + obj.SetHeightInFeet(fernTestValueHeightInFeet) + assert.Equal(t, fernTestValueHeightInFeet, obj.HeightInFeet) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetPlantedDate", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValuePlantedDate *time.Time + obj.SetPlantedDate(fernTestValuePlantedDate) + assert.Equal(t, fernTestValuePlantedDate, obj.PlantedDate) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersTreeRecord(t *testing.T) { + t.Run("GetID", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var expected string + obj.ID = expected // Act & Assert - assert.Nil(t, obj.GetResults(), "getter should return nil when property is nil") + assert.Equal(t, expected, obj.GetID(), "getter should return the property value") }) - t.Run("GetResults_NilReceiver", func(t *testing.T) { + t.Run("GetID_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleTypeSearchResponse + var obj *TreeRecord // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetResults() // Should return zero value + _ = obj.GetID() // Should return zero value + }) + + t.Run("GetTreeName", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var expected *string + obj.TreeName = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeName(), "getter should return the property value") + }) + + t.Run("GetTreeName_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + obj.TreeName = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeName(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeName_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeName() // Should return zero value + }) + + t.Run("GetTreeDescription", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var expected *string + obj.TreeDescription = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeDescription(), "getter should return the property value") + }) + + t.Run("GetTreeDescription_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + obj.TreeDescription = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeDescription(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeDescription_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeDescription() // Should return zero value + }) + + t.Run("GetTreeSpecies", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var expected *string + obj.TreeSpecies = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeSpecies(), "getter should return the property value") + }) + + t.Run("GetTreeSpecies_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + obj.TreeSpecies = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeSpecies(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeSpecies_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeSpecies() // Should return zero value + }) + + t.Run("GetHeightInFeet", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var expected *float64 + obj.HeightInFeet = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetHeightInFeet(), "getter should return the property value") + }) + + t.Run("GetHeightInFeet_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + obj.HeightInFeet = nil + + // Act & Assert + assert.Nil(t, obj.GetHeightInFeet(), "getter should return nil when property is nil") + }) + + t.Run("GetHeightInFeet_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetHeightInFeet() // Should return zero value + }) + + t.Run("GetPlantedDate", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var expected *time.Time + obj.PlantedDate = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetPlantedDate(), "getter should return the property value") + }) + + t.Run("GetPlantedDate_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + obj.PlantedDate = nil + + // Act & Assert + assert.Nil(t, obj.GetPlantedDate(), "getter should return nil when property is nil") + }) + + t.Run("GetPlantedDate_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetPlantedDate() // Should return zero value + }) + +} + +func TestSettersMarkExplicitTreeRecord(t *testing.T) { + t.Run("SetID_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var fernTestValueID string + + // Act + obj.SetID(fernTestValueID) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetTreeName_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var fernTestValueTreeName *string + + // Act + obj.SetTreeName(fernTestValueTreeName) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("GetPaging", func(t *testing.T) { + t.Run("SetTreeDescription_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} - var expected *PagingCursors - obj.Paging = expected + obj := &TreeRecord{} + var fernTestValueTreeDescription *string - // Act & Assert - assert.Equal(t, expected, obj.GetPaging(), "getter should return the property value") + // Act + obj.SetTreeDescription(fernTestValueTreeDescription) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("GetPaging_NilValue", func(t *testing.T) { + t.Run("SetTreeSpecies_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} - obj.Paging = nil + obj := &TreeRecord{} + var fernTestValueTreeSpecies *string - // Act & Assert - assert.Nil(t, obj.GetPaging(), "getter should return nil when property is nil") - }) + // Act + obj.SetTreeSpecies(fernTestValueTreeSpecies) - t.Run("GetPaging_NilReceiver", func(t *testing.T) { - t.Parallel() - var obj *RuleTypeSearchResponse - // Should not panic - getters should handle nil receiver gracefully - defer func() { - if r := recover(); r != nil { - t.Errorf("Getter panicked on nil receiver: %v", r) - } - }() - _ = obj.GetPaging() // Should return zero value - }) + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") -} + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } -func TestSettersMarkExplicitRuleTypeSearchResponse(t *testing.T) { - t.Run("SetResults_MarksExplicit", func(t *testing.T) { + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetHeightInFeet_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} - var fernTestValueResults []*RuleType + obj := &TreeRecord{} + var fernTestValueHeightInFeet *float64 // Act - obj.SetResults(fernTestValueResults) + obj.SetHeightInFeet(fernTestValueHeightInFeet) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2900,14 +4571,14 @@ func TestSettersMarkExplicitRuleTypeSearchResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetPaging_MarksExplicit", func(t *testing.T) { + t.Run("SetPlantedDate_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} - var fernTestValuePaging *PagingCursors + obj := &TreeRecord{} + var fernTestValuePlantedDate *time.Time // Act - obj.SetPaging(fernTestValuePaging) + obj.SetPlantedDate(fernTestValuePlantedDate) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -3582,6 +5253,72 @@ func TestJSONMarshalingPagingCursors(t *testing.T) { }) } +func TestJSONMarshalingPlantBase(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantBase{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled PlantBase + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj PlantBase + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj PlantBase + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + +func TestJSONMarshalingPlantStrict(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantStrict{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled PlantStrict + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj PlantStrict + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj PlantStrict + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + func TestJSONMarshalingRuleResponse(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() @@ -3642,17 +5379,149 @@ func TestJSONMarshalingRuleType(t *testing.T) { t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj RuleType + var obj RuleType + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + +func TestJSONMarshalingRuleTypeSearchResponse(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled RuleTypeSearchResponse + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj RuleTypeSearchResponse + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj RuleTypeSearchResponse + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + +func TestJSONMarshalingTreeBase(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled TreeBase + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj TreeBase + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj TreeBase + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + +func TestJSONMarshalingTreeDescribable(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled TreeDescribable + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj TreeDescribable + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj TreeDescribable + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + +func TestJSONMarshalingTreeIdentifiable(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeIdentifiable{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled TreeIdentifiable + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj TreeIdentifiable + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj TreeIdentifiable err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingRuleTypeSearchResponse(t *testing.T) { +func TestJSONMarshalingTreeRecord(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} + obj := &TreeRecord{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3661,21 +5530,21 @@ func TestJSONMarshalingRuleTypeSearchResponse(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled RuleTypeSearchResponse + var unmarshaled TreeRecord err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj RuleTypeSearchResponse + var obj TreeRecord err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj RuleTypeSearchResponse + var obj TreeRecord err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) @@ -3923,6 +5792,38 @@ func TestStringPagingCursors(t *testing.T) { }) } +func TestStringPlantBase(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &PlantBase{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *PlantBase + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + +func TestStringPlantStrict(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &PlantStrict{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *PlantStrict + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + func TestStringRuleResponse(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() @@ -3971,6 +5872,70 @@ func TestStringRuleTypeSearchResponse(t *testing.T) { }) } +func TestStringTreeBase(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &TreeBase{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + +func TestStringTreeDescribable(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &TreeDescribable{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeDescribable + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + +func TestStringTreeIdentifiable(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &TreeIdentifiable{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeIdentifiable + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + +func TestStringTreeRecord(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &TreeRecord{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + func TestStringUser(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() @@ -4032,6 +5997,85 @@ func TestEnumCombinedEntityStatus(t *testing.T) { }) } +func TestEnumPlantBaseWateringFrequency(t *testing.T) { + t.Run("NewFromString_daily", func(t *testing.T) { + t.Parallel() + val, err := NewPlantBaseWateringFrequencyFromString("daily") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantBaseWateringFrequency("daily"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_weekly", func(t *testing.T) { + t.Parallel() + val, err := NewPlantBaseWateringFrequencyFromString("weekly") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantBaseWateringFrequency("weekly"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_biweekly", func(t *testing.T) { + t.Parallel() + val, err := NewPlantBaseWateringFrequencyFromString("biweekly") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantBaseWateringFrequency("biweekly"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_monthly", func(t *testing.T) { + t.Parallel() + val, err := NewPlantBaseWateringFrequencyFromString("monthly") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantBaseWateringFrequency("monthly"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_Invalid", func(t *testing.T) { + _, err := NewPlantBaseWateringFrequencyFromString("invalid_value_that_does_not_exist") + assert.Error(t, err) + }) + + t.Run("Ptr", func(t *testing.T) { + val, err := NewPlantBaseWateringFrequencyFromString("daily") + assert.NoError(t, err) + ptr := val.Ptr() + assert.NotNil(t, ptr) + assert.Equal(t, val, *ptr) + }) +} + +func TestEnumPlantPostSunExposure(t *testing.T) { + t.Run("NewFromString_full", func(t *testing.T) { + t.Parallel() + val, err := NewPlantPostSunExposureFromString("full") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantPostSunExposure("full"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_partial", func(t *testing.T) { + t.Parallel() + val, err := NewPlantPostSunExposureFromString("partial") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantPostSunExposure("partial"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_shade", func(t *testing.T) { + t.Parallel() + val, err := NewPlantPostSunExposureFromString("shade") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantPostSunExposure("shade"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_Invalid", func(t *testing.T) { + _, err := NewPlantPostSunExposureFromString("invalid_value_that_does_not_exist") + assert.Error(t, err) + }) + + t.Run("Ptr", func(t *testing.T) { + val, err := NewPlantPostSunExposureFromString("full") + assert.NoError(t, err) + ptr := val.Ptr() + assert.NotNil(t, ptr) + assert.Equal(t, val, *ptr) + }) +} + func TestEnumRuleCreateRequestExecutionContext(t *testing.T) { t.Run("NewFromString_prod", func(t *testing.T) { t.Parallel() @@ -4393,6 +6437,52 @@ func TestExtraPropertiesPagingCursors(t *testing.T) { }) } +func TestExtraPropertiesPlantBase(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &PlantBase{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *PlantBase + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + +func TestExtraPropertiesPlantStrict(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &PlantStrict{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *PlantStrict + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + func TestExtraPropertiesRuleResponse(t *testing.T) { t.Run("GetExtraProperties", func(t *testing.T) { t.Parallel() @@ -4462,6 +6552,98 @@ func TestExtraPropertiesRuleTypeSearchResponse(t *testing.T) { }) } +func TestExtraPropertiesTreeBase(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &TreeBase{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + +func TestExtraPropertiesTreeDescribable(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &TreeDescribable{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeDescribable + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + +func TestExtraPropertiesTreeIdentifiable(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &TreeIdentifiable{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeIdentifiable + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + +func TestExtraPropertiesTreeRecord(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &TreeRecord{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + func TestExtraPropertiesUser(t *testing.T) { t.Run("GetExtraProperties", func(t *testing.T) { t.Parallel() diff --git a/seed/java-sdk/allof/.fern/metadata.json b/seed/java-sdk/allof/.fern/metadata.json index 8558dfdb1357..e52ea4749838 100644 --- a/seed/java-sdk/allof/.fern/metadata.json +++ b/seed/java-sdk/allof/.fern/metadata.json @@ -3,8 +3,7 @@ "generatorName": "fernapi/fern-java-sdk", "generatorVersion": "local", "originGitCommit": "DUMMY", - "invokedBy": "ci", + "invokedBy": "manual", "requestedVersion": "0.0.1", - "ciProvider": "github", "sdkVersion": "0.0.1" } \ No newline at end of file diff --git a/seed/java-sdk/allof/reference.md b/seed/java-sdk/allof/reference.md index 3ffb31766886..1011897f6693 100644 --- a/seed/java-sdk/allof/reference.md +++ b/seed/java-sdk/allof/reference.md @@ -172,3 +172,140 @@ client.getOrganization(); +
client.createPlant(request) -> PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```java +client.createPlant( + PlantPost + .builder() + .species("species") + .family("family") + .genus("genus") + .sunExposure(PlantPostSunExposure.FULL) + .build() +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**sunExposure:** `PlantPostSunExposure` — Required sun exposure level. + +
+
+ +
+
+ +**plantedAt:** `Optional` — Date the plant was planted. + +
+
+ +
+
+ +**soilType:** `Optional` — Preferred soil type. + +
+
+
+
+ + +
+
+
+ +
client.createTree(request) -> TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```java +client.createTree( + TreeRecord + .builder() + .id("id") + .build() +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TreeRecord` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/java-sdk/allof/snippet.json b/seed/java-sdk/allof/snippet.json index 27798905f781..112264b30073 100644 --- a/seed/java-sdk/allof/snippet.json +++ b/seed/java-sdk/allof/snippet.json @@ -64,6 +64,32 @@ "sync_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.getOrganization();\n }\n}\n", "async_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.getOrganization();\n }\n}\n" } + }, + { + "example_identifier": "3b9bdc0d", + "id": { + "method": "POST", + "path": "/plants", + "identifier_override": "endpoint_.createPlant" + }, + "snippet": { + "type": "java", + "sync_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\nimport com.seed.api.requests.PlantPost;\nimport com.seed.api.types.PlantPostSunExposure;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.createPlant(\n PlantPost\n .builder()\n .species(\"species\")\n .family(\"family\")\n .genus(\"genus\")\n .sunExposure(PlantPostSunExposure.FULL)\n .build()\n );\n }\n}\n", + "async_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\nimport com.seed.api.requests.PlantPost;\nimport com.seed.api.types.PlantPostSunExposure;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.createPlant(\n PlantPost\n .builder()\n .species(\"species\")\n .family(\"family\")\n .genus(\"genus\")\n .sunExposure(PlantPostSunExposure.FULL)\n .build()\n );\n }\n}\n" + } + }, + { + "example_identifier": "79a98995", + "id": { + "method": "POST", + "path": "/trees", + "identifier_override": "endpoint_.createTree" + }, + "snippet": { + "type": "java", + "sync_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\nimport com.seed.api.types.TreeRecord;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.createTree(\n TreeRecord\n .builder()\n .id(\"id\")\n .build()\n );\n }\n}\n", + "async_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\nimport com.seed.api.types.TreeRecord;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.createTree(\n TreeRecord\n .builder()\n .id(\"id\")\n .build()\n );\n }\n}\n" + } } ], "types": {} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/AsyncRawSeedApiClient.java b/seed/java-sdk/allof/src/main/java/com/seed/api/AsyncRawSeedApiClient.java index 046deb483a7f..c18818939fc1 100644 --- a/seed/java-sdk/allof/src/main/java/com/seed/api/AsyncRawSeedApiClient.java +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/AsyncRawSeedApiClient.java @@ -12,12 +12,15 @@ import com.seed.api.core.SeedApiApiException; import com.seed.api.core.SeedApiException; import com.seed.api.core.SeedApiHttpResponse; +import com.seed.api.requests.PlantPost; import com.seed.api.requests.RuleCreateRequest; import com.seed.api.requests.SearchRuleTypesRequest; import com.seed.api.types.CombinedEntity; import com.seed.api.types.Organization; +import com.seed.api.types.PlantStrict; import com.seed.api.types.RuleResponse; import com.seed.api.types.RuleTypeSearchResponse; +import com.seed.api.types.TreeRecord; import com.seed.api.types.UserSearchResponse; import java.io.IOException; import java.util.concurrent.CompletableFuture; @@ -320,4 +323,136 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { }); return future; } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public CompletableFuture> createPlant(PlantPost request) { + return createPlant(request, null); + } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public CompletableFuture> createPlant( + PlantPost request, RequestOptions requestOptions) { + HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) + .newBuilder() + .addPathSegments("plants"); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((_key, _value) -> { + httpUrl.addQueryParameter(_key, _value); + }); + } + RequestBody body; + try { + body = RequestBody.create( + ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + } catch (JsonProcessingException e) { + throw new SeedApiException("Failed to serialize request", e); + } + Request okhttpRequest = new Request.Builder() + .url(httpUrl.build()) + .method("POST", body) + .headers(Headers.of(clientOptions.headers(requestOptions))) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .build(); + OkHttpClient client = clientOptions.httpClient(); + if (requestOptions != null && requestOptions.getTimeout().isPresent()) { + client = clientOptions.httpClientWithTimeout(requestOptions); + } + CompletableFuture> future = new CompletableFuture<>(); + client.newCall(okhttpRequest).enqueue(new Callback() { + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { + try (ResponseBody responseBody = response.body()) { + String responseBodyString = responseBody != null ? responseBody.string() : "{}"; + if (response.isSuccessful()) { + future.complete(new SeedApiHttpResponse<>( + ObjectMappers.JSON_MAPPER.readValue(responseBodyString, PlantStrict.class), response)); + return; + } + Object errorBody = ObjectMappers.parseErrorBody(responseBodyString); + future.completeExceptionally(new SeedApiApiException( + "Error with status code " + response.code(), response.code(), errorBody, response)); + return; + } catch (IOException e) { + future.completeExceptionally(new SeedApiException("Network error executing HTTP request", e)); + } + } + + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) { + future.completeExceptionally(new SeedApiException("Network error executing HTTP request", e)); + } + }); + return future; + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public CompletableFuture> createTree(TreeRecord request) { + return createTree(request, null); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public CompletableFuture> createTree( + TreeRecord request, RequestOptions requestOptions) { + HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) + .newBuilder() + .addPathSegments("trees"); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((_key, _value) -> { + httpUrl.addQueryParameter(_key, _value); + }); + } + RequestBody body; + try { + body = RequestBody.create( + ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + } catch (JsonProcessingException e) { + throw new SeedApiException("Failed to serialize request", e); + } + Request okhttpRequest = new Request.Builder() + .url(httpUrl.build()) + .method("POST", body) + .headers(Headers.of(clientOptions.headers(requestOptions))) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .build(); + OkHttpClient client = clientOptions.httpClient(); + if (requestOptions != null && requestOptions.getTimeout().isPresent()) { + client = clientOptions.httpClientWithTimeout(requestOptions); + } + CompletableFuture> future = new CompletableFuture<>(); + client.newCall(okhttpRequest).enqueue(new Callback() { + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { + try (ResponseBody responseBody = response.body()) { + String responseBodyString = responseBody != null ? responseBody.string() : "{}"; + if (response.isSuccessful()) { + future.complete(new SeedApiHttpResponse<>( + ObjectMappers.JSON_MAPPER.readValue(responseBodyString, TreeRecord.class), response)); + return; + } + Object errorBody = ObjectMappers.parseErrorBody(responseBodyString); + future.completeExceptionally(new SeedApiApiException( + "Error with status code " + response.code(), response.code(), errorBody, response)); + return; + } catch (IOException e) { + future.completeExceptionally(new SeedApiException("Network error executing HTTP request", e)); + } + } + + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) { + future.completeExceptionally(new SeedApiException("Network error executing HTTP request", e)); + } + }); + return future; + } } diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/AsyncSeedApiClient.java b/seed/java-sdk/allof/src/main/java/com/seed/api/AsyncSeedApiClient.java index 45b759db04ed..20e5132175ef 100644 --- a/seed/java-sdk/allof/src/main/java/com/seed/api/AsyncSeedApiClient.java +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/AsyncSeedApiClient.java @@ -5,12 +5,15 @@ import com.seed.api.core.ClientOptions; import com.seed.api.core.RequestOptions; +import com.seed.api.requests.PlantPost; import com.seed.api.requests.RuleCreateRequest; import com.seed.api.requests.SearchRuleTypesRequest; import com.seed.api.types.CombinedEntity; import com.seed.api.types.Organization; +import com.seed.api.types.PlantStrict; import com.seed.api.types.RuleResponse; import com.seed.api.types.RuleTypeSearchResponse; +import com.seed.api.types.TreeRecord; import com.seed.api.types.UserSearchResponse; import java.util.concurrent.CompletableFuture; @@ -80,6 +83,34 @@ public CompletableFuture getOrganization(RequestOptions requestOpt return this.rawClient.getOrganization(requestOptions).thenApply(response -> response.body()); } + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public CompletableFuture createPlant(PlantPost request) { + return this.rawClient.createPlant(request).thenApply(response -> response.body()); + } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public CompletableFuture createPlant(PlantPost request, RequestOptions requestOptions) { + return this.rawClient.createPlant(request, requestOptions).thenApply(response -> response.body()); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public CompletableFuture createTree(TreeRecord request) { + return this.rawClient.createTree(request).thenApply(response -> response.body()); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public CompletableFuture createTree(TreeRecord request, RequestOptions requestOptions) { + return this.rawClient.createTree(request, requestOptions).thenApply(response -> response.body()); + } + public static AsyncSeedApiClientBuilder builder() { return new AsyncSeedApiClientBuilder(); } diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/RawSeedApiClient.java b/seed/java-sdk/allof/src/main/java/com/seed/api/RawSeedApiClient.java index f9a763a744c4..c98320c38ccf 100644 --- a/seed/java-sdk/allof/src/main/java/com/seed/api/RawSeedApiClient.java +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/RawSeedApiClient.java @@ -12,12 +12,15 @@ import com.seed.api.core.SeedApiApiException; import com.seed.api.core.SeedApiException; import com.seed.api.core.SeedApiHttpResponse; +import com.seed.api.requests.PlantPost; import com.seed.api.requests.RuleCreateRequest; import com.seed.api.requests.SearchRuleTypesRequest; import com.seed.api.types.CombinedEntity; import com.seed.api.types.Organization; +import com.seed.api.types.PlantStrict; import com.seed.api.types.RuleResponse; import com.seed.api.types.RuleTypeSearchResponse; +import com.seed.api.types.TreeRecord; import com.seed.api.types.UserSearchResponse; import java.io.IOException; import okhttp3.Headers; @@ -246,4 +249,108 @@ public SeedApiHttpResponse getOrganization(RequestOptions requestO throw new SeedApiException("Network error executing HTTP request", e); } } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public SeedApiHttpResponse createPlant(PlantPost request) { + return createPlant(request, null); + } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public SeedApiHttpResponse createPlant(PlantPost request, RequestOptions requestOptions) { + HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) + .newBuilder() + .addPathSegments("plants"); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((_key, _value) -> { + httpUrl.addQueryParameter(_key, _value); + }); + } + RequestBody body; + try { + body = RequestBody.create( + ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + } catch (JsonProcessingException e) { + throw new SeedApiException("Failed to serialize request", e); + } + Request okhttpRequest = new Request.Builder() + .url(httpUrl.build()) + .method("POST", body) + .headers(Headers.of(clientOptions.headers(requestOptions))) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .build(); + OkHttpClient client = clientOptions.httpClient(); + if (requestOptions != null && requestOptions.getTimeout().isPresent()) { + client = clientOptions.httpClientWithTimeout(requestOptions); + } + try (Response response = client.newCall(okhttpRequest).execute()) { + ResponseBody responseBody = response.body(); + String responseBodyString = responseBody != null ? responseBody.string() : "{}"; + if (response.isSuccessful()) { + return new SeedApiHttpResponse<>( + ObjectMappers.JSON_MAPPER.readValue(responseBodyString, PlantStrict.class), response); + } + Object errorBody = ObjectMappers.parseErrorBody(responseBodyString); + throw new SeedApiApiException( + "Error with status code " + response.code(), response.code(), errorBody, response); + } catch (IOException e) { + throw new SeedApiException("Network error executing HTTP request", e); + } + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public SeedApiHttpResponse createTree(TreeRecord request) { + return createTree(request, null); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public SeedApiHttpResponse createTree(TreeRecord request, RequestOptions requestOptions) { + HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) + .newBuilder() + .addPathSegments("trees"); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((_key, _value) -> { + httpUrl.addQueryParameter(_key, _value); + }); + } + RequestBody body; + try { + body = RequestBody.create( + ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + } catch (JsonProcessingException e) { + throw new SeedApiException("Failed to serialize request", e); + } + Request okhttpRequest = new Request.Builder() + .url(httpUrl.build()) + .method("POST", body) + .headers(Headers.of(clientOptions.headers(requestOptions))) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .build(); + OkHttpClient client = clientOptions.httpClient(); + if (requestOptions != null && requestOptions.getTimeout().isPresent()) { + client = clientOptions.httpClientWithTimeout(requestOptions); + } + try (Response response = client.newCall(okhttpRequest).execute()) { + ResponseBody responseBody = response.body(); + String responseBodyString = responseBody != null ? responseBody.string() : "{}"; + if (response.isSuccessful()) { + return new SeedApiHttpResponse<>( + ObjectMappers.JSON_MAPPER.readValue(responseBodyString, TreeRecord.class), response); + } + Object errorBody = ObjectMappers.parseErrorBody(responseBodyString); + throw new SeedApiApiException( + "Error with status code " + response.code(), response.code(), errorBody, response); + } catch (IOException e) { + throw new SeedApiException("Network error executing HTTP request", e); + } + } } diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/SeedApiClient.java b/seed/java-sdk/allof/src/main/java/com/seed/api/SeedApiClient.java index 73a73c0a87a2..cb5ec6826828 100644 --- a/seed/java-sdk/allof/src/main/java/com/seed/api/SeedApiClient.java +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/SeedApiClient.java @@ -5,12 +5,15 @@ import com.seed.api.core.ClientOptions; import com.seed.api.core.RequestOptions; +import com.seed.api.requests.PlantPost; import com.seed.api.requests.RuleCreateRequest; import com.seed.api.requests.SearchRuleTypesRequest; import com.seed.api.types.CombinedEntity; import com.seed.api.types.Organization; +import com.seed.api.types.PlantStrict; import com.seed.api.types.RuleResponse; import com.seed.api.types.RuleTypeSearchResponse; +import com.seed.api.types.TreeRecord; import com.seed.api.types.UserSearchResponse; public class SeedApiClient { @@ -78,6 +81,34 @@ public Organization getOrganization(RequestOptions requestOptions) { return this.rawClient.getOrganization(requestOptions).body(); } + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public PlantStrict createPlant(PlantPost request) { + return this.rawClient.createPlant(request).body(); + } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public PlantStrict createPlant(PlantPost request, RequestOptions requestOptions) { + return this.rawClient.createPlant(request, requestOptions).body(); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public TreeRecord createTree(TreeRecord request) { + return this.rawClient.createTree(request).body(); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public TreeRecord createTree(TreeRecord request, RequestOptions requestOptions) { + return this.rawClient.createTree(request, requestOptions).body(); + } + public static SeedApiClientBuilder builder() { return new SeedApiClientBuilder(); } diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/requests/PlantPost.java b/seed/java-sdk/allof/src/main/java/com/seed/api/requests/PlantPost.java new file mode 100644 index 000000000000..6501dfbdcdc1 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/requests/PlantPost.java @@ -0,0 +1,422 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.requests; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import com.seed.api.types.IPlantBase; +import com.seed.api.types.IPlantStrict; +import com.seed.api.types.PlantBaseWateringFrequency; +import com.seed.api.types.PlantPostSunExposure; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = PlantPost.Builder.class) +public final class PlantPost implements IPlantBase, IPlantStrict { + private final Optional commonName; + + private final Optional wateringFrequency; + + private final String species; + + private final String family; + + private final String genus; + + private final PlantPostSunExposure sunExposure; + + private final Optional plantedAt; + + private final Optional soilType; + + private final Map additionalProperties; + + private PlantPost( + Optional commonName, + Optional wateringFrequency, + String species, + String family, + String genus, + PlantPostSunExposure sunExposure, + Optional plantedAt, + Optional soilType, + Map additionalProperties) { + this.commonName = commonName; + this.wateringFrequency = wateringFrequency; + this.species = species; + this.family = family; + this.genus = genus; + this.sunExposure = sunExposure; + this.plantedAt = plantedAt; + this.soilType = soilType; + this.additionalProperties = additionalProperties; + } + + /** + * @return The common name of the plant. + */ + @JsonProperty("commonName") + @java.lang.Override + public Optional getCommonName() { + return commonName; + } + + @JsonProperty("wateringFrequency") + public Optional getWateringFrequency() { + return wateringFrequency; + } + + /** + * @return The botanical species name. + */ + @JsonProperty("species") + @java.lang.Override + public String getSpecies() { + return species; + } + + /** + * @return The botanical family. + */ + @JsonProperty("family") + @java.lang.Override + public String getFamily() { + return family; + } + + /** + * @return The botanical genus. + */ + @JsonProperty("genus") + @java.lang.Override + public String getGenus() { + return genus; + } + + /** + * @return Required sun exposure level. + */ + @JsonProperty("sunExposure") + public PlantPostSunExposure getSunExposure() { + return sunExposure; + } + + /** + * @return Date the plant was planted. + */ + @JsonProperty("plantedAt") + public Optional getPlantedAt() { + return plantedAt; + } + + /** + * @return Preferred soil type. + */ + @JsonProperty("soilType") + public Optional getSoilType() { + return soilType; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof PlantPost && equalTo((PlantPost) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(PlantPost other) { + return commonName.equals(other.commonName) + && wateringFrequency.equals(other.wateringFrequency) + && species.equals(other.species) + && family.equals(other.family) + && genus.equals(other.genus) + && sunExposure.equals(other.sunExposure) + && plantedAt.equals(other.plantedAt) + && soilType.equals(other.soilType); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.commonName, + this.wateringFrequency, + this.species, + this.family, + this.genus, + this.sunExposure, + this.plantedAt, + this.soilType); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static SpeciesStage builder() { + return new Builder(); + } + + public interface SpeciesStage { + /** + *

The botanical species name.

+ */ + FamilyStage species(@NotNull String species); + + Builder from(PlantPost other); + } + + public interface FamilyStage { + /** + *

The botanical family.

+ */ + GenusStage family(@NotNull String family); + } + + public interface GenusStage { + /** + *

The botanical genus.

+ */ + SunExposureStage genus(@NotNull String genus); + } + + public interface SunExposureStage { + /** + *

Required sun exposure level.

+ */ + _FinalStage sunExposure(@NotNull PlantPostSunExposure sunExposure); + } + + public interface _FinalStage { + PlantPost build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

The common name of the plant.

+ */ + _FinalStage commonName(Optional commonName); + + _FinalStage commonName(String commonName); + + _FinalStage wateringFrequency(Optional wateringFrequency); + + _FinalStage wateringFrequency(PlantBaseWateringFrequency wateringFrequency); + + /** + *

Date the plant was planted.

+ */ + _FinalStage plantedAt(Optional plantedAt); + + _FinalStage plantedAt(String plantedAt); + + /** + *

Preferred soil type.

+ */ + _FinalStage soilType(Optional soilType); + + _FinalStage soilType(String soilType); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements SpeciesStage, FamilyStage, GenusStage, SunExposureStage, _FinalStage { + private String species; + + private String family; + + private String genus; + + private PlantPostSunExposure sunExposure; + + private Optional soilType = Optional.empty(); + + private Optional plantedAt = Optional.empty(); + + private Optional wateringFrequency = Optional.empty(); + + private Optional commonName = Optional.empty(); + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(PlantPost other) { + commonName(other.getCommonName()); + wateringFrequency(other.getWateringFrequency()); + species(other.getSpecies()); + family(other.getFamily()); + genus(other.getGenus()); + sunExposure(other.getSunExposure()); + plantedAt(other.getPlantedAt()); + soilType(other.getSoilType()); + return this; + } + + /** + *

The botanical species name.

+ *

The botanical species name.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("species") + public FamilyStage species(@NotNull String species) { + this.species = Objects.requireNonNull(species, "species must not be null"); + return this; + } + + /** + *

The botanical family.

+ *

The botanical family.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("family") + public GenusStage family(@NotNull String family) { + this.family = Objects.requireNonNull(family, "family must not be null"); + return this; + } + + /** + *

The botanical genus.

+ *

The botanical genus.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("genus") + public SunExposureStage genus(@NotNull String genus) { + this.genus = Objects.requireNonNull(genus, "genus must not be null"); + return this; + } + + /** + *

Required sun exposure level.

+ *

Required sun exposure level.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("sunExposure") + public _FinalStage sunExposure(@NotNull PlantPostSunExposure sunExposure) { + this.sunExposure = Objects.requireNonNull(sunExposure, "sunExposure must not be null"); + return this; + } + + /** + *

Preferred soil type.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage soilType(String soilType) { + this.soilType = Optional.ofNullable(soilType); + return this; + } + + /** + *

Preferred soil type.

+ */ + @java.lang.Override + @JsonSetter(value = "soilType", nulls = Nulls.SKIP) + public _FinalStage soilType(Optional soilType) { + this.soilType = soilType; + return this; + } + + /** + *

Date the plant was planted.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage plantedAt(String plantedAt) { + this.plantedAt = Optional.ofNullable(plantedAt); + return this; + } + + /** + *

Date the plant was planted.

+ */ + @java.lang.Override + @JsonSetter(value = "plantedAt", nulls = Nulls.SKIP) + public _FinalStage plantedAt(Optional plantedAt) { + this.plantedAt = plantedAt; + return this; + } + + @java.lang.Override + public _FinalStage wateringFrequency(PlantBaseWateringFrequency wateringFrequency) { + this.wateringFrequency = Optional.ofNullable(wateringFrequency); + return this; + } + + @java.lang.Override + @JsonSetter(value = "wateringFrequency", nulls = Nulls.SKIP) + public _FinalStage wateringFrequency(Optional wateringFrequency) { + this.wateringFrequency = wateringFrequency; + return this; + } + + /** + *

The common name of the plant.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage commonName(String commonName) { + this.commonName = Optional.ofNullable(commonName); + return this; + } + + /** + *

The common name of the plant.

+ */ + @java.lang.Override + @JsonSetter(value = "commonName", nulls = Nulls.SKIP) + public _FinalStage commonName(Optional commonName) { + this.commonName = commonName; + return this; + } + + @java.lang.Override + public PlantPost build() { + return new PlantPost( + commonName, + wateringFrequency, + species, + family, + genus, + sunExposure, + plantedAt, + soilType, + additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/IPlantBase.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/IPlantBase.java new file mode 100644 index 000000000000..345f9a9e3f77 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/IPlantBase.java @@ -0,0 +1,12 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import java.util.Optional; + +public interface IPlantBase extends IPlantStrict { + Optional getCommonName(); + + Optional getWateringFrequency(); +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/IPlantStrict.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/IPlantStrict.java new file mode 100644 index 000000000000..7af793b74710 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/IPlantStrict.java @@ -0,0 +1,12 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +public interface IPlantStrict { + String getSpecies(); + + String getFamily(); + + String getGenus(); +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeBase.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeBase.java new file mode 100644 index 000000000000..85bf6cdbf4bb --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeBase.java @@ -0,0 +1,12 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import java.util.Optional; + +public interface ITreeBase extends ITreeIdentifiable, ITreeDescribable { + Optional getTreeSpecies(); + + Optional getHeightInFeet(); +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeDescribable.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeDescribable.java new file mode 100644 index 000000000000..c379dad73c93 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeDescribable.java @@ -0,0 +1,12 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import java.util.Optional; + +public interface ITreeDescribable { + Optional getTreeName(); + + Optional getTreeDescription(); +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeIdentifiable.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeIdentifiable.java new file mode 100644 index 000000000000..79e66c107eb0 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/ITreeIdentifiable.java @@ -0,0 +1,8 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +public interface ITreeIdentifiable { + String getId(); +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantBase.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantBase.java new file mode 100644 index 000000000000..690683a6fb15 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantBase.java @@ -0,0 +1,280 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = PlantBase.Builder.class) +public final class PlantBase implements IPlantBase, IPlantStrict { + private final Optional commonName; + + private final Optional wateringFrequency; + + private final String species; + + private final String family; + + private final String genus; + + private final Map additionalProperties; + + private PlantBase( + Optional commonName, + Optional wateringFrequency, + String species, + String family, + String genus, + Map additionalProperties) { + this.commonName = commonName; + this.wateringFrequency = wateringFrequency; + this.species = species; + this.family = family; + this.genus = genus; + this.additionalProperties = additionalProperties; + } + + /** + * @return The common name of the plant. + */ + @JsonProperty("commonName") + @java.lang.Override + public Optional getCommonName() { + return commonName; + } + + @JsonProperty("wateringFrequency") + public Optional getWateringFrequency() { + return wateringFrequency; + } + + /** + * @return The botanical species name. + */ + @JsonProperty("species") + @java.lang.Override + public String getSpecies() { + return species; + } + + /** + * @return The botanical family. + */ + @JsonProperty("family") + @java.lang.Override + public String getFamily() { + return family; + } + + /** + * @return The botanical genus. + */ + @JsonProperty("genus") + @java.lang.Override + public String getGenus() { + return genus; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof PlantBase && equalTo((PlantBase) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(PlantBase other) { + return commonName.equals(other.commonName) + && wateringFrequency.equals(other.wateringFrequency) + && species.equals(other.species) + && family.equals(other.family) + && genus.equals(other.genus); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.commonName, this.wateringFrequency, this.species, this.family, this.genus); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static SpeciesStage builder() { + return new Builder(); + } + + public interface SpeciesStage { + /** + *

The botanical species name.

+ */ + FamilyStage species(@NotNull String species); + + Builder from(PlantBase other); + } + + public interface FamilyStage { + /** + *

The botanical family.

+ */ + GenusStage family(@NotNull String family); + } + + public interface GenusStage { + /** + *

The botanical genus.

+ */ + _FinalStage genus(@NotNull String genus); + } + + public interface _FinalStage { + PlantBase build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

The common name of the plant.

+ */ + _FinalStage commonName(Optional commonName); + + _FinalStage commonName(String commonName); + + _FinalStage wateringFrequency(Optional wateringFrequency); + + _FinalStage wateringFrequency(PlantBaseWateringFrequency wateringFrequency); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements SpeciesStage, FamilyStage, GenusStage, _FinalStage { + private String species; + + private String family; + + private String genus; + + private Optional wateringFrequency = Optional.empty(); + + private Optional commonName = Optional.empty(); + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(PlantBase other) { + commonName(other.getCommonName()); + wateringFrequency(other.getWateringFrequency()); + species(other.getSpecies()); + family(other.getFamily()); + genus(other.getGenus()); + return this; + } + + /** + *

The botanical species name.

+ *

The botanical species name.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("species") + public FamilyStage species(@NotNull String species) { + this.species = Objects.requireNonNull(species, "species must not be null"); + return this; + } + + /** + *

The botanical family.

+ *

The botanical family.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("family") + public GenusStage family(@NotNull String family) { + this.family = Objects.requireNonNull(family, "family must not be null"); + return this; + } + + /** + *

The botanical genus.

+ *

The botanical genus.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("genus") + public _FinalStage genus(@NotNull String genus) { + this.genus = Objects.requireNonNull(genus, "genus must not be null"); + return this; + } + + @java.lang.Override + public _FinalStage wateringFrequency(PlantBaseWateringFrequency wateringFrequency) { + this.wateringFrequency = Optional.ofNullable(wateringFrequency); + return this; + } + + @java.lang.Override + @JsonSetter(value = "wateringFrequency", nulls = Nulls.SKIP) + public _FinalStage wateringFrequency(Optional wateringFrequency) { + this.wateringFrequency = wateringFrequency; + return this; + } + + /** + *

The common name of the plant.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage commonName(String commonName) { + this.commonName = Optional.ofNullable(commonName); + return this; + } + + /** + *

The common name of the plant.

+ */ + @java.lang.Override + @JsonSetter(value = "commonName", nulls = Nulls.SKIP) + public _FinalStage commonName(Optional commonName) { + this.commonName = commonName; + return this; + } + + @java.lang.Override + public PlantBase build() { + return new PlantBase(commonName, wateringFrequency, species, family, genus, additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantBaseWateringFrequency.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantBaseWateringFrequency.java new file mode 100644 index 000000000000..e6556c10f9a1 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantBaseWateringFrequency.java @@ -0,0 +1,105 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class PlantBaseWateringFrequency { + public static final PlantBaseWateringFrequency BIWEEKLY = + new PlantBaseWateringFrequency(Value.BIWEEKLY, "biweekly"); + + public static final PlantBaseWateringFrequency DAILY = new PlantBaseWateringFrequency(Value.DAILY, "daily"); + + public static final PlantBaseWateringFrequency WEEKLY = new PlantBaseWateringFrequency(Value.WEEKLY, "weekly"); + + public static final PlantBaseWateringFrequency MONTHLY = new PlantBaseWateringFrequency(Value.MONTHLY, "monthly"); + + private final Value value; + + private final String string; + + PlantBaseWateringFrequency(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof PlantBaseWateringFrequency + && this.string.equals(((PlantBaseWateringFrequency) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case BIWEEKLY: + return visitor.visitBiweekly(); + case DAILY: + return visitor.visitDaily(); + case WEEKLY: + return visitor.visitWeekly(); + case MONTHLY: + return visitor.visitMonthly(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static PlantBaseWateringFrequency valueOf(String value) { + switch (value) { + case "biweekly": + return BIWEEKLY; + case "daily": + return DAILY; + case "weekly": + return WEEKLY; + case "monthly": + return MONTHLY; + default: + return new PlantBaseWateringFrequency(Value.UNKNOWN, value); + } + } + + public enum Value { + DAILY, + + WEEKLY, + + BIWEEKLY, + + MONTHLY, + + UNKNOWN + } + + public interface Visitor { + T visitDaily(); + + T visitWeekly(); + + T visitBiweekly(); + + T visitMonthly(); + + T visitUnknown(String unknownType); + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantPostSunExposure.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantPostSunExposure.java new file mode 100644 index 000000000000..a9648170fa44 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantPostSunExposure.java @@ -0,0 +1,93 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class PlantPostSunExposure { + public static final PlantPostSunExposure FULL = new PlantPostSunExposure(Value.FULL, "full"); + + public static final PlantPostSunExposure PARTIAL = new PlantPostSunExposure(Value.PARTIAL, "partial"); + + public static final PlantPostSunExposure SHADE = new PlantPostSunExposure(Value.SHADE, "shade"); + + private final Value value; + + private final String string; + + PlantPostSunExposure(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof PlantPostSunExposure && this.string.equals(((PlantPostSunExposure) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case FULL: + return visitor.visitFull(); + case PARTIAL: + return visitor.visitPartial(); + case SHADE: + return visitor.visitShade(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static PlantPostSunExposure valueOf(String value) { + switch (value) { + case "full": + return FULL; + case "partial": + return PARTIAL; + case "shade": + return SHADE; + default: + return new PlantPostSunExposure(Value.UNKNOWN, value); + } + } + + public enum Value { + FULL, + + PARTIAL, + + SHADE, + + UNKNOWN + } + + public interface Visitor { + T visitFull(); + + T visitPartial(); + + T visitShade(); + + T visitUnknown(String unknownType); + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantStrict.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantStrict.java new file mode 100644 index 000000000000..6006c3404528 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/PlantStrict.java @@ -0,0 +1,198 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = PlantStrict.Builder.class) +public final class PlantStrict implements IPlantStrict { + private final String species; + + private final String family; + + private final String genus; + + private final Map additionalProperties; + + private PlantStrict(String species, String family, String genus, Map additionalProperties) { + this.species = species; + this.family = family; + this.genus = genus; + this.additionalProperties = additionalProperties; + } + + /** + * @return The botanical species name. + */ + @JsonProperty("species") + @java.lang.Override + public String getSpecies() { + return species; + } + + /** + * @return The botanical family. + */ + @JsonProperty("family") + @java.lang.Override + public String getFamily() { + return family; + } + + /** + * @return The botanical genus. + */ + @JsonProperty("genus") + @java.lang.Override + public String getGenus() { + return genus; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof PlantStrict && equalTo((PlantStrict) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(PlantStrict other) { + return species.equals(other.species) && family.equals(other.family) && genus.equals(other.genus); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.species, this.family, this.genus); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static SpeciesStage builder() { + return new Builder(); + } + + public interface SpeciesStage { + /** + *

The botanical species name.

+ */ + FamilyStage species(@NotNull String species); + + Builder from(PlantStrict other); + } + + public interface FamilyStage { + /** + *

The botanical family.

+ */ + GenusStage family(@NotNull String family); + } + + public interface GenusStage { + /** + *

The botanical genus.

+ */ + _FinalStage genus(@NotNull String genus); + } + + public interface _FinalStage { + PlantStrict build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements SpeciesStage, FamilyStage, GenusStage, _FinalStage { + private String species; + + private String family; + + private String genus; + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(PlantStrict other) { + species(other.getSpecies()); + family(other.getFamily()); + genus(other.getGenus()); + return this; + } + + /** + *

The botanical species name.

+ *

The botanical species name.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("species") + public FamilyStage species(@NotNull String species) { + this.species = Objects.requireNonNull(species, "species must not be null"); + return this; + } + + /** + *

The botanical family.

+ *

The botanical family.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("family") + public GenusStage family(@NotNull String family) { + this.family = Objects.requireNonNull(family, "family must not be null"); + return this; + } + + /** + *

The botanical genus.

+ *

The botanical genus.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("genus") + public _FinalStage genus(@NotNull String genus) { + this.genus = Objects.requireNonNull(genus, "genus must not be null"); + return this; + } + + @java.lang.Override + public PlantStrict build() { + return new PlantStrict(species, family, genus, additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeBase.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeBase.java new file mode 100644 index 000000000000..b6510fda49b0 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeBase.java @@ -0,0 +1,310 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = TreeBase.Builder.class) +public final class TreeBase implements ITreeBase, ITreeIdentifiable, ITreeDescribable { + private final Optional treeSpecies; + + private final Optional heightInFeet; + + private final String id; + + private final Optional treeName; + + private final Optional treeDescription; + + private final Map additionalProperties; + + private TreeBase( + Optional treeSpecies, + Optional heightInFeet, + String id, + Optional treeName, + Optional treeDescription, + Map additionalProperties) { + this.treeSpecies = treeSpecies; + this.heightInFeet = heightInFeet; + this.id = id; + this.treeName = treeName; + this.treeDescription = treeDescription; + this.additionalProperties = additionalProperties; + } + + /** + * @return The species of tree. + */ + @JsonProperty("treeSpecies") + @java.lang.Override + public Optional getTreeSpecies() { + return treeSpecies; + } + + /** + * @return Height of the tree in feet. + */ + @JsonProperty("heightInFeet") + @java.lang.Override + public Optional getHeightInFeet() { + return heightInFeet; + } + + /** + * @return Unique tree identifier. + */ + @JsonProperty("id") + @java.lang.Override + public String getId() { + return id; + } + + /** + * @return Display name of the tree. + */ + @JsonProperty("treeName") + @java.lang.Override + public Optional getTreeName() { + return treeName; + } + + /** + * @return A description of the tree. + */ + @JsonProperty("treeDescription") + @java.lang.Override + public Optional getTreeDescription() { + return treeDescription; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof TreeBase && equalTo((TreeBase) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(TreeBase other) { + return treeSpecies.equals(other.treeSpecies) + && heightInFeet.equals(other.heightInFeet) + && id.equals(other.id) + && treeName.equals(other.treeName) + && treeDescription.equals(other.treeDescription); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.treeSpecies, this.heightInFeet, this.id, this.treeName, this.treeDescription); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static IdStage builder() { + return new Builder(); + } + + public interface IdStage { + /** + *

Unique tree identifier.

+ */ + _FinalStage id(@NotNull String id); + + Builder from(TreeBase other); + } + + public interface _FinalStage { + TreeBase build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

The species of tree.

+ */ + _FinalStage treeSpecies(Optional treeSpecies); + + _FinalStage treeSpecies(String treeSpecies); + + /** + *

Height of the tree in feet.

+ */ + _FinalStage heightInFeet(Optional heightInFeet); + + _FinalStage heightInFeet(Double heightInFeet); + + /** + *

Display name of the tree.

+ */ + _FinalStage treeName(Optional treeName); + + _FinalStage treeName(String treeName); + + /** + *

A description of the tree.

+ */ + _FinalStage treeDescription(Optional treeDescription); + + _FinalStage treeDescription(String treeDescription); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements IdStage, _FinalStage { + private String id; + + private Optional treeDescription = Optional.empty(); + + private Optional treeName = Optional.empty(); + + private Optional heightInFeet = Optional.empty(); + + private Optional treeSpecies = Optional.empty(); + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(TreeBase other) { + treeSpecies(other.getTreeSpecies()); + heightInFeet(other.getHeightInFeet()); + id(other.getId()); + treeName(other.getTreeName()); + treeDescription(other.getTreeDescription()); + return this; + } + + /** + *

Unique tree identifier.

+ *

Unique tree identifier.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("id") + public _FinalStage id(@NotNull String id) { + this.id = Objects.requireNonNull(id, "id must not be null"); + return this; + } + + /** + *

A description of the tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage treeDescription(String treeDescription) { + this.treeDescription = Optional.ofNullable(treeDescription); + return this; + } + + /** + *

A description of the tree.

+ */ + @java.lang.Override + @JsonSetter(value = "treeDescription", nulls = Nulls.SKIP) + public _FinalStage treeDescription(Optional treeDescription) { + this.treeDescription = treeDescription; + return this; + } + + /** + *

Display name of the tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage treeName(String treeName) { + this.treeName = Optional.ofNullable(treeName); + return this; + } + + /** + *

Display name of the tree.

+ */ + @java.lang.Override + @JsonSetter(value = "treeName", nulls = Nulls.SKIP) + public _FinalStage treeName(Optional treeName) { + this.treeName = treeName; + return this; + } + + /** + *

Height of the tree in feet.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage heightInFeet(Double heightInFeet) { + this.heightInFeet = Optional.ofNullable(heightInFeet); + return this; + } + + /** + *

Height of the tree in feet.

+ */ + @java.lang.Override + @JsonSetter(value = "heightInFeet", nulls = Nulls.SKIP) + public _FinalStage heightInFeet(Optional heightInFeet) { + this.heightInFeet = heightInFeet; + return this; + } + + /** + *

The species of tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage treeSpecies(String treeSpecies) { + this.treeSpecies = Optional.ofNullable(treeSpecies); + return this; + } + + /** + *

The species of tree.

+ */ + @java.lang.Override + @JsonSetter(value = "treeSpecies", nulls = Nulls.SKIP) + public _FinalStage treeSpecies(Optional treeSpecies) { + this.treeSpecies = treeSpecies; + return this; + } + + @java.lang.Override + public TreeBase build() { + return new TreeBase(treeSpecies, heightInFeet, id, treeName, treeDescription, additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeDescribable.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeDescribable.java new file mode 100644 index 000000000000..f88e12a96613 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeDescribable.java @@ -0,0 +1,142 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = TreeDescribable.Builder.class) +public final class TreeDescribable implements ITreeDescribable { + private final Optional treeName; + + private final Optional treeDescription; + + private final Map additionalProperties; + + private TreeDescribable( + Optional treeName, Optional treeDescription, Map additionalProperties) { + this.treeName = treeName; + this.treeDescription = treeDescription; + this.additionalProperties = additionalProperties; + } + + /** + * @return Display name of the tree. + */ + @JsonProperty("treeName") + @java.lang.Override + public Optional getTreeName() { + return treeName; + } + + /** + * @return A description of the tree. + */ + @JsonProperty("treeDescription") + @java.lang.Override + public Optional getTreeDescription() { + return treeDescription; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof TreeDescribable && equalTo((TreeDescribable) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(TreeDescribable other) { + return treeName.equals(other.treeName) && treeDescription.equals(other.treeDescription); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.treeName, this.treeDescription); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static Builder builder() { + return new Builder(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder { + private Optional treeName = Optional.empty(); + + private Optional treeDescription = Optional.empty(); + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + public Builder from(TreeDescribable other) { + treeName(other.getTreeName()); + treeDescription(other.getTreeDescription()); + return this; + } + + /** + *

Display name of the tree.

+ */ + @JsonSetter(value = "treeName", nulls = Nulls.SKIP) + public Builder treeName(Optional treeName) { + this.treeName = treeName; + return this; + } + + public Builder treeName(String treeName) { + this.treeName = Optional.ofNullable(treeName); + return this; + } + + /** + *

A description of the tree.

+ */ + @JsonSetter(value = "treeDescription", nulls = Nulls.SKIP) + public Builder treeDescription(Optional treeDescription) { + this.treeDescription = treeDescription; + return this; + } + + public Builder treeDescription(String treeDescription) { + this.treeDescription = Optional.ofNullable(treeDescription); + return this; + } + + public TreeDescribable build() { + return new TreeDescribable(treeName, treeDescription, additionalProperties); + } + + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeIdentifiable.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeIdentifiable.java new file mode 100644 index 000000000000..ac4f8c5e4267 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeIdentifiable.java @@ -0,0 +1,130 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = TreeIdentifiable.Builder.class) +public final class TreeIdentifiable implements ITreeIdentifiable { + private final String id; + + private final Map additionalProperties; + + private TreeIdentifiable(String id, Map additionalProperties) { + this.id = id; + this.additionalProperties = additionalProperties; + } + + /** + * @return Unique tree identifier. + */ + @JsonProperty("id") + @java.lang.Override + public String getId() { + return id; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof TreeIdentifiable && equalTo((TreeIdentifiable) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(TreeIdentifiable other) { + return id.equals(other.id); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.id); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static IdStage builder() { + return new Builder(); + } + + public interface IdStage { + /** + *

Unique tree identifier.

+ */ + _FinalStage id(@NotNull String id); + + Builder from(TreeIdentifiable other); + } + + public interface _FinalStage { + TreeIdentifiable build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements IdStage, _FinalStage { + private String id; + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(TreeIdentifiable other) { + id(other.getId()); + return this; + } + + /** + *

Unique tree identifier.

+ *

Unique tree identifier.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("id") + public _FinalStage id(@NotNull String id) { + this.id = Objects.requireNonNull(id, "id must not be null"); + return this; + } + + @java.lang.Override + public TreeIdentifiable build() { + return new TreeIdentifiable(id, additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeRecord.java b/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeRecord.java new file mode 100644 index 000000000000..0700fc5ca1d9 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/seed/api/types/TreeRecord.java @@ -0,0 +1,355 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = TreeRecord.Builder.class) +public final class TreeRecord implements ITreeBase, ITreeIdentifiable, ITreeDescribable { + private final Optional treeSpecies; + + private final Optional heightInFeet; + + private final String id; + + private final Optional treeName; + + private final Optional treeDescription; + + private final Optional plantedDate; + + private final Map additionalProperties; + + private TreeRecord( + Optional treeSpecies, + Optional heightInFeet, + String id, + Optional treeName, + Optional treeDescription, + Optional plantedDate, + Map additionalProperties) { + this.treeSpecies = treeSpecies; + this.heightInFeet = heightInFeet; + this.id = id; + this.treeName = treeName; + this.treeDescription = treeDescription; + this.plantedDate = plantedDate; + this.additionalProperties = additionalProperties; + } + + /** + * @return The species of tree. + */ + @JsonProperty("treeSpecies") + @java.lang.Override + public Optional getTreeSpecies() { + return treeSpecies; + } + + /** + * @return Height of the tree in feet. + */ + @JsonProperty("heightInFeet") + @java.lang.Override + public Optional getHeightInFeet() { + return heightInFeet; + } + + /** + * @return Unique tree identifier. + */ + @JsonProperty("id") + @java.lang.Override + public String getId() { + return id; + } + + /** + * @return Display name of the tree. + */ + @JsonProperty("treeName") + @java.lang.Override + public Optional getTreeName() { + return treeName; + } + + /** + * @return A description of the tree. + */ + @JsonProperty("treeDescription") + @java.lang.Override + public Optional getTreeDescription() { + return treeDescription; + } + + /** + * @return Date the tree was planted. + */ + @JsonProperty("plantedDate") + public Optional getPlantedDate() { + return plantedDate; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof TreeRecord && equalTo((TreeRecord) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(TreeRecord other) { + return treeSpecies.equals(other.treeSpecies) + && heightInFeet.equals(other.heightInFeet) + && id.equals(other.id) + && treeName.equals(other.treeName) + && treeDescription.equals(other.treeDescription) + && plantedDate.equals(other.plantedDate); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.treeSpecies, this.heightInFeet, this.id, this.treeName, this.treeDescription, this.plantedDate); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static IdStage builder() { + return new Builder(); + } + + public interface IdStage { + /** + *

Unique tree identifier.

+ */ + _FinalStage id(@NotNull String id); + + Builder from(TreeRecord other); + } + + public interface _FinalStage { + TreeRecord build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

The species of tree.

+ */ + _FinalStage treeSpecies(Optional treeSpecies); + + _FinalStage treeSpecies(String treeSpecies); + + /** + *

Height of the tree in feet.

+ */ + _FinalStage heightInFeet(Optional heightInFeet); + + _FinalStage heightInFeet(Double heightInFeet); + + /** + *

Display name of the tree.

+ */ + _FinalStage treeName(Optional treeName); + + _FinalStage treeName(String treeName); + + /** + *

A description of the tree.

+ */ + _FinalStage treeDescription(Optional treeDescription); + + _FinalStage treeDescription(String treeDescription); + + /** + *

Date the tree was planted.

+ */ + _FinalStage plantedDate(Optional plantedDate); + + _FinalStage plantedDate(String plantedDate); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements IdStage, _FinalStage { + private String id; + + private Optional plantedDate = Optional.empty(); + + private Optional treeDescription = Optional.empty(); + + private Optional treeName = Optional.empty(); + + private Optional heightInFeet = Optional.empty(); + + private Optional treeSpecies = Optional.empty(); + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(TreeRecord other) { + treeSpecies(other.getTreeSpecies()); + heightInFeet(other.getHeightInFeet()); + id(other.getId()); + treeName(other.getTreeName()); + treeDescription(other.getTreeDescription()); + plantedDate(other.getPlantedDate()); + return this; + } + + /** + *

Unique tree identifier.

+ *

Unique tree identifier.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("id") + public _FinalStage id(@NotNull String id) { + this.id = Objects.requireNonNull(id, "id must not be null"); + return this; + } + + /** + *

Date the tree was planted.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage plantedDate(String plantedDate) { + this.plantedDate = Optional.ofNullable(plantedDate); + return this; + } + + /** + *

Date the tree was planted.

+ */ + @java.lang.Override + @JsonSetter(value = "plantedDate", nulls = Nulls.SKIP) + public _FinalStage plantedDate(Optional plantedDate) { + this.plantedDate = plantedDate; + return this; + } + + /** + *

A description of the tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage treeDescription(String treeDescription) { + this.treeDescription = Optional.ofNullable(treeDescription); + return this; + } + + /** + *

A description of the tree.

+ */ + @java.lang.Override + @JsonSetter(value = "treeDescription", nulls = Nulls.SKIP) + public _FinalStage treeDescription(Optional treeDescription) { + this.treeDescription = treeDescription; + return this; + } + + /** + *

Display name of the tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage treeName(String treeName) { + this.treeName = Optional.ofNullable(treeName); + return this; + } + + /** + *

Display name of the tree.

+ */ + @java.lang.Override + @JsonSetter(value = "treeName", nulls = Nulls.SKIP) + public _FinalStage treeName(Optional treeName) { + this.treeName = treeName; + return this; + } + + /** + *

Height of the tree in feet.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage heightInFeet(Double heightInFeet) { + this.heightInFeet = Optional.ofNullable(heightInFeet); + return this; + } + + /** + *

Height of the tree in feet.

+ */ + @java.lang.Override + @JsonSetter(value = "heightInFeet", nulls = Nulls.SKIP) + public _FinalStage heightInFeet(Optional heightInFeet) { + this.heightInFeet = heightInFeet; + return this; + } + + /** + *

The species of tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage treeSpecies(String treeSpecies) { + this.treeSpecies = Optional.ofNullable(treeSpecies); + return this; + } + + /** + *

The species of tree.

+ */ + @java.lang.Override + @JsonSetter(value = "treeSpecies", nulls = Nulls.SKIP) + public _FinalStage treeSpecies(Optional treeSpecies) { + this.treeSpecies = treeSpecies; + return this; + } + + @java.lang.Override + public TreeRecord build() { + return new TreeRecord( + treeSpecies, heightInFeet, id, treeName, treeDescription, plantedDate, additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/snippets/Example10.java b/seed/java-sdk/allof/src/main/java/com/snippets/Example10.java new file mode 100644 index 000000000000..5f53e49bee4d --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/snippets/Example10.java @@ -0,0 +1,19 @@ +package com.snippets; + +import com.seed.api.SeedApiClient; +import com.seed.api.requests.PlantPost; +import com.seed.api.types.PlantPostSunExposure; + +public class Example10 { + public static void main(String[] args) { + SeedApiClient client = + SeedApiClient.builder().url("https://api.fern.com").build(); + + client.createPlant(PlantPost.builder() + .species("species") + .family("family") + .genus("genus") + .sunExposure(PlantPostSunExposure.FULL) + .build()); + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/snippets/Example11.java b/seed/java-sdk/allof/src/main/java/com/snippets/Example11.java new file mode 100644 index 000000000000..f0a53e03babc --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/snippets/Example11.java @@ -0,0 +1,24 @@ +package com.snippets; + +import com.seed.api.SeedApiClient; +import com.seed.api.requests.PlantPost; +import com.seed.api.types.PlantBaseWateringFrequency; +import com.seed.api.types.PlantPostSunExposure; + +public class Example11 { + public static void main(String[] args) { + SeedApiClient client = + SeedApiClient.builder().url("https://api.fern.com").build(); + + client.createPlant(PlantPost.builder() + .species("species") + .family("family") + .genus("genus") + .sunExposure(PlantPostSunExposure.FULL) + .commonName("commonName") + .wateringFrequency(PlantBaseWateringFrequency.DAILY) + .plantedAt("2023-01-15") + .soilType("soilType") + .build()); + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/snippets/Example12.java b/seed/java-sdk/allof/src/main/java/com/snippets/Example12.java new file mode 100644 index 000000000000..7c51829e8d66 --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/snippets/Example12.java @@ -0,0 +1,13 @@ +package com.snippets; + +import com.seed.api.SeedApiClient; +import com.seed.api.types.TreeRecord; + +public class Example12 { + public static void main(String[] args) { + SeedApiClient client = + SeedApiClient.builder().url("https://api.fern.com").build(); + + client.createTree(TreeRecord.builder().id("id").build()); + } +} diff --git a/seed/java-sdk/allof/src/main/java/com/snippets/Example13.java b/seed/java-sdk/allof/src/main/java/com/snippets/Example13.java new file mode 100644 index 000000000000..20d41e0a67ea --- /dev/null +++ b/seed/java-sdk/allof/src/main/java/com/snippets/Example13.java @@ -0,0 +1,20 @@ +package com.snippets; + +import com.seed.api.SeedApiClient; +import com.seed.api.types.TreeRecord; + +public class Example13 { + public static void main(String[] args) { + SeedApiClient client = + SeedApiClient.builder().url("https://api.fern.com").build(); + + client.createTree(TreeRecord.builder() + .id("id") + .treeSpecies("treeSpecies") + .heightInFeet(1.1) + .treeName("treeName") + .treeDescription("treeDescription") + .plantedDate("2023-01-15") + .build()); + } +} diff --git a/seed/python-sdk/allof/no-custom-config/.fern/metadata.json b/seed/python-sdk/allof/no-custom-config/.fern/metadata.json index 2a49c5f592ee..46cf3f37ec30 100644 --- a/seed/python-sdk/allof/no-custom-config/.fern/metadata.json +++ b/seed/python-sdk/allof/no-custom-config/.fern/metadata.json @@ -6,8 +6,7 @@ "enable_wire_tests": true }, "originGitCommit": "DUMMY", - "invokedBy": "ci", + "invokedBy": "manual", "requestedVersion": "0.0.1", - "ciProvider": "github", "sdkVersion": "0.0.1" } \ No newline at end of file diff --git a/seed/python-sdk/allof/no-custom-config/reference.md b/seed/python-sdk/allof/no-custom-config/reference.md index c1e2e6167abc..aad59286b4d7 100644 --- a/seed/python-sdk/allof/no-custom-config/reference.md +++ b/seed/python-sdk/allof/no-custom-config/reference.md @@ -266,3 +266,206 @@ client.get_organization() +
client.create_plant(...) -> PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from seed import SeedApi +from seed.environment import SeedApiEnvironment + +client = SeedApi( + environment=SeedApiEnvironment.DEFAULT, +) + +client.create_plant( + species="species", + family="family", + genus="genus", + sun_exposure="full", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**species:** `str` — The botanical species name. + +
+
+ +
+
+ +**family:** `str` — The botanical family. + +
+
+ +
+
+ +**genus:** `str` — The botanical genus. + +
+
+ +
+
+ +**sun_exposure:** `PlantPostSunExposure` — Required sun exposure level. + +
+
+ +
+
+ +**common_name:** `typing.Optional[str]` — The common name of the plant. + +
+
+ +
+
+ +**watering_frequency:** `typing.Optional[PlantBaseWateringFrequency]` + +
+
+ +
+
+ +**planted_at:** `typing.Optional[datetime.date]` — Date the plant was planted. + +
+
+ +
+
+ +**soil_type:** `typing.Optional[str]` — Preferred soil type. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.create_tree(...) -> TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from seed import SeedApi +from seed.environment import SeedApiEnvironment + +client = SeedApi( + environment=SeedApiEnvironment.DEFAULT, +) + +client.create_tree( + id="id", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TreeRecord` + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ diff --git a/seed/python-sdk/allof/no-custom-config/snippet.json b/seed/python-sdk/allof/no-custom-config/snippet.json index f000c2ddf786..ae064edfa6c1 100644 --- a/seed/python-sdk/allof/no-custom-config/snippet.json +++ b/seed/python-sdk/allof/no-custom-config/snippet.json @@ -65,6 +65,32 @@ "async_client": "import asyncio\n\nfrom seed import AsyncSeedApi\n\nclient = AsyncSeedApi()\n\n\nasync def main() -> None:\n await client.get_organization()\n\n\nasyncio.run(main())\n", "type": "python" } + }, + { + "example_identifier": "default", + "id": { + "path": "/plants", + "method": "POST", + "identifier_override": "endpoint_.createPlant" + }, + "snippet": { + "sync_client": "from seed import SeedApi\n\nclient = SeedApi()\nclient.create_plant(\n species=\"species\",\n family=\"family\",\n genus=\"genus\",\n sun_exposure=\"full\",\n)\n", + "async_client": "import asyncio\n\nfrom seed import AsyncSeedApi\n\nclient = AsyncSeedApi()\n\n\nasync def main() -> None:\n await client.create_plant(\n species=\"species\",\n family=\"family\",\n genus=\"genus\",\n sun_exposure=\"full\",\n )\n\n\nasyncio.run(main())\n", + "type": "python" + } + }, + { + "example_identifier": "default", + "id": { + "path": "/trees", + "method": "POST", + "identifier_override": "endpoint_.createTree" + }, + "snippet": { + "sync_client": "from seed import SeedApi\n\nclient = SeedApi()\nclient.create_tree(\n id=\"id\",\n)\n", + "async_client": "import asyncio\n\nfrom seed import AsyncSeedApi\n\nclient = AsyncSeedApi()\n\n\nasync def main() -> None:\n await client.create_tree(\n id=\"id\",\n )\n\n\nasyncio.run(main())\n", + "type": "python" + } } ] } \ No newline at end of file diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/__init__.py b/seed/python-sdk/allof/no-custom-config/src/seed/__init__.py index 7c97354d9fba..73e3ee8401db 100644 --- a/seed/python-sdk/allof/no-custom-config/src/seed/__init__.py +++ b/seed/python-sdk/allof/no-custom-config/src/seed/__init__.py @@ -19,12 +19,20 @@ Organization, PaginatedResult, PagingCursors, + PlantBase, + PlantBaseWateringFrequency, + PlantPostSunExposure, + PlantStrict, RuleCreateRequestExecutionContext, RuleExecutionContext, RuleResponse, RuleResponseStatus, RuleType, RuleTypeSearchResponse, + TreeBase, + TreeDescribable, + TreeIdentifiable, + TreeRecord, User, UserSearchResponse, ) @@ -48,6 +56,10 @@ "Organization": ".types", "PaginatedResult": ".types", "PagingCursors": ".types", + "PlantBase": ".types", + "PlantBaseWateringFrequency": ".types", + "PlantPostSunExposure": ".types", + "PlantStrict": ".types", "RuleCreateRequestExecutionContext": ".types", "RuleExecutionContext": ".types", "RuleResponse": ".types", @@ -56,6 +68,10 @@ "RuleTypeSearchResponse": ".types", "SeedApi": ".client", "SeedApiEnvironment": ".environment", + "TreeBase": ".types", + "TreeDescribable": ".types", + "TreeIdentifiable": ".types", + "TreeRecord": ".types", "User": ".types", "UserSearchResponse": ".types", "__version__": ".version", @@ -99,6 +115,10 @@ def __dir__(): "Organization", "PaginatedResult", "PagingCursors", + "PlantBase", + "PlantBaseWateringFrequency", + "PlantPostSunExposure", + "PlantStrict", "RuleCreateRequestExecutionContext", "RuleExecutionContext", "RuleResponse", @@ -107,6 +127,10 @@ def __dir__(): "RuleTypeSearchResponse", "SeedApi", "SeedApiEnvironment", + "TreeBase", + "TreeDescribable", + "TreeIdentifiable", + "TreeRecord", "User", "UserSearchResponse", "__version__", diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/client.py b/seed/python-sdk/allof/no-custom-config/src/seed/client.py index 8335dca12e75..e69c8fd6e2cd 100644 --- a/seed/python-sdk/allof/no-custom-config/src/seed/client.py +++ b/seed/python-sdk/allof/no-custom-config/src/seed/client.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import datetime as dt import typing import httpx @@ -10,9 +11,13 @@ from .raw_client import AsyncRawSeedApi, RawSeedApi from .types.combined_entity import CombinedEntity from .types.organization import Organization +from .types.plant_base_watering_frequency import PlantBaseWateringFrequency +from .types.plant_post_sun_exposure import PlantPostSunExposure +from .types.plant_strict import PlantStrict from .types.rule_create_request_execution_context import RuleCreateRequestExecutionContext from .types.rule_response import RuleResponse from .types.rule_type_search_response import RuleTypeSearchResponse +from .types.tree_record import TreeRecord from .types.user_search_response import UserSearchResponse # this is used as the default value for optional parameters @@ -233,6 +238,142 @@ def get_organization(self, *, request_options: typing.Optional[RequestOptions] = _response = self._raw_client.get_organization(request_options=request_options) return _response.data + def create_plant( + self, + *, + sun_exposure: PlantPostSunExposure, + species: str, + family: str, + genus: str, + planted_at: typing.Optional[dt.date] = OMIT, + soil_type: typing.Optional[str] = OMIT, + common_name: typing.Optional[str] = OMIT, + watering_frequency: typing.Optional[PlantBaseWateringFrequency] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PlantStrict: + """ + Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + + Parameters + ---------- + sun_exposure : PlantPostSunExposure + Required sun exposure level. + + species : str + The botanical species name. + + family : str + The botanical family. + + genus : str + The botanical genus. + + planted_at : typing.Optional[dt.date] + Date the plant was planted. + + soil_type : typing.Optional[str] + Preferred soil type. + + common_name : typing.Optional[str] + The common name of the plant. + + watering_frequency : typing.Optional[PlantBaseWateringFrequency] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PlantStrict + Created plant + + Examples + -------- + from seed import SeedApi + + client = SeedApi() + client.create_plant( + species="species", + family="family", + genus="genus", + sun_exposure="full", + ) + """ + _response = self._raw_client.create_plant( + sun_exposure=sun_exposure, + species=species, + family=family, + genus=genus, + planted_at=planted_at, + soil_type=soil_type, + common_name=common_name, + watering_frequency=watering_frequency, + request_options=request_options, + ) + return _response.data + + def create_tree( + self, + *, + id: str, + planted_date: typing.Optional[dt.date] = OMIT, + tree_species: typing.Optional[str] = OMIT, + height_in_feet: typing.Optional[float] = OMIT, + tree_name: typing.Optional[str] = OMIT, + tree_description: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> TreeRecord: + """ + Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + + Parameters + ---------- + id : str + Unique tree identifier. + + planted_date : typing.Optional[dt.date] + Date the tree was planted. + + tree_species : typing.Optional[str] + The species of tree. + + height_in_feet : typing.Optional[float] + Height of the tree in feet. + + tree_name : typing.Optional[str] + Display name of the tree. + + tree_description : typing.Optional[str] + A description of the tree. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TreeRecord + Created tree + + Examples + -------- + from seed import SeedApi + + client = SeedApi() + client.create_tree( + id="id", + ) + """ + _response = self._raw_client.create_tree( + id=id, + planted_date=planted_date, + tree_species=tree_species, + height_in_feet=height_in_feet, + tree_name=tree_name, + tree_description=tree_description, + request_options=request_options, + ) + return _response.data + def _make_default_async_client( timeout: typing.Optional[float], @@ -504,6 +645,158 @@ async def main() -> None: _response = await self._raw_client.get_organization(request_options=request_options) return _response.data + async def create_plant( + self, + *, + sun_exposure: PlantPostSunExposure, + species: str, + family: str, + genus: str, + planted_at: typing.Optional[dt.date] = OMIT, + soil_type: typing.Optional[str] = OMIT, + common_name: typing.Optional[str] = OMIT, + watering_frequency: typing.Optional[PlantBaseWateringFrequency] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PlantStrict: + """ + Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + + Parameters + ---------- + sun_exposure : PlantPostSunExposure + Required sun exposure level. + + species : str + The botanical species name. + + family : str + The botanical family. + + genus : str + The botanical genus. + + planted_at : typing.Optional[dt.date] + Date the plant was planted. + + soil_type : typing.Optional[str] + Preferred soil type. + + common_name : typing.Optional[str] + The common name of the plant. + + watering_frequency : typing.Optional[PlantBaseWateringFrequency] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PlantStrict + Created plant + + Examples + -------- + import asyncio + + from seed import AsyncSeedApi + + client = AsyncSeedApi() + + + async def main() -> None: + await client.create_plant( + species="species", + family="family", + genus="genus", + sun_exposure="full", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_plant( + sun_exposure=sun_exposure, + species=species, + family=family, + genus=genus, + planted_at=planted_at, + soil_type=soil_type, + common_name=common_name, + watering_frequency=watering_frequency, + request_options=request_options, + ) + return _response.data + + async def create_tree( + self, + *, + id: str, + planted_date: typing.Optional[dt.date] = OMIT, + tree_species: typing.Optional[str] = OMIT, + height_in_feet: typing.Optional[float] = OMIT, + tree_name: typing.Optional[str] = OMIT, + tree_description: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> TreeRecord: + """ + Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + + Parameters + ---------- + id : str + Unique tree identifier. + + planted_date : typing.Optional[dt.date] + Date the tree was planted. + + tree_species : typing.Optional[str] + The species of tree. + + height_in_feet : typing.Optional[float] + Height of the tree in feet. + + tree_name : typing.Optional[str] + Display name of the tree. + + tree_description : typing.Optional[str] + A description of the tree. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TreeRecord + Created tree + + Examples + -------- + import asyncio + + from seed import AsyncSeedApi + + client = AsyncSeedApi() + + + async def main() -> None: + await client.create_tree( + id="id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_tree( + id=id, + planted_date=planted_date, + tree_species=tree_species, + height_in_feet=height_in_feet, + tree_name=tree_name, + tree_description=tree_description, + request_options=request_options, + ) + return _response.data + def _get_base_url(*, base_url: typing.Optional[str] = None, environment: SeedApiEnvironment) -> str: if base_url is not None: diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/raw_client.py b/seed/python-sdk/allof/no-custom-config/src/seed/raw_client.py index 77122c88976e..692811fb0ea8 100644 --- a/seed/python-sdk/allof/no-custom-config/src/seed/raw_client.py +++ b/seed/python-sdk/allof/no-custom-config/src/seed/raw_client.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import datetime as dt import typing from json.decoder import JSONDecodeError @@ -11,9 +12,13 @@ from .core.request_options import RequestOptions from .types.combined_entity import CombinedEntity from .types.organization import Organization +from .types.plant_base_watering_frequency import PlantBaseWateringFrequency +from .types.plant_post_sun_exposure import PlantPostSunExposure +from .types.plant_strict import PlantStrict from .types.rule_create_request_execution_context import RuleCreateRequestExecutionContext from .types.rule_response import RuleResponse from .types.rule_type_search_response import RuleTypeSearchResponse +from .types.tree_record import TreeRecord from .types.user_search_response import UserSearchResponse from pydantic import ValidationError @@ -235,6 +240,168 @@ def get_organization( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def create_plant( + self, + *, + sun_exposure: PlantPostSunExposure, + species: str, + family: str, + genus: str, + planted_at: typing.Optional[dt.date] = OMIT, + soil_type: typing.Optional[str] = OMIT, + common_name: typing.Optional[str] = OMIT, + watering_frequency: typing.Optional[PlantBaseWateringFrequency] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[PlantStrict]: + """ + Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + + Parameters + ---------- + sun_exposure : PlantPostSunExposure + Required sun exposure level. + + species : str + The botanical species name. + + family : str + The botanical family. + + genus : str + The botanical genus. + + planted_at : typing.Optional[dt.date] + Date the plant was planted. + + soil_type : typing.Optional[str] + Preferred soil type. + + common_name : typing.Optional[str] + The common name of the plant. + + watering_frequency : typing.Optional[PlantBaseWateringFrequency] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[PlantStrict] + Created plant + """ + _response = self._client_wrapper.httpx_client.request( + "plants", + method="POST", + json={ + "sunExposure": sun_exposure, + "plantedAt": planted_at, + "soilType": soil_type, + "commonName": common_name, + "wateringFrequency": watering_frequency, + "species": species, + "family": family, + "genus": genus, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PlantStrict, + parse_obj_as( + type_=PlantStrict, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def create_tree( + self, + *, + id: str, + planted_date: typing.Optional[dt.date] = OMIT, + tree_species: typing.Optional[str] = OMIT, + height_in_feet: typing.Optional[float] = OMIT, + tree_name: typing.Optional[str] = OMIT, + tree_description: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[TreeRecord]: + """ + Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + + Parameters + ---------- + id : str + Unique tree identifier. + + planted_date : typing.Optional[dt.date] + Date the tree was planted. + + tree_species : typing.Optional[str] + The species of tree. + + height_in_feet : typing.Optional[float] + Height of the tree in feet. + + tree_name : typing.Optional[str] + Display name of the tree. + + tree_description : typing.Optional[str] + A description of the tree. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TreeRecord] + Created tree + """ + _response = self._client_wrapper.httpx_client.request( + "trees", + method="POST", + json={ + "plantedDate": planted_date, + "treeSpecies": tree_species, + "heightInFeet": height_in_feet, + "id": id, + "treeName": tree_name, + "treeDescription": tree_description, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TreeRecord, + parse_obj_as( + type_=TreeRecord, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + class AsyncRawSeedApi: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -451,3 +618,165 @@ async def get_organization( status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_plant( + self, + *, + sun_exposure: PlantPostSunExposure, + species: str, + family: str, + genus: str, + planted_at: typing.Optional[dt.date] = OMIT, + soil_type: typing.Optional[str] = OMIT, + common_name: typing.Optional[str] = OMIT, + watering_frequency: typing.Optional[PlantBaseWateringFrequency] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[PlantStrict]: + """ + Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + + Parameters + ---------- + sun_exposure : PlantPostSunExposure + Required sun exposure level. + + species : str + The botanical species name. + + family : str + The botanical family. + + genus : str + The botanical genus. + + planted_at : typing.Optional[dt.date] + Date the plant was planted. + + soil_type : typing.Optional[str] + Preferred soil type. + + common_name : typing.Optional[str] + The common name of the plant. + + watering_frequency : typing.Optional[PlantBaseWateringFrequency] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[PlantStrict] + Created plant + """ + _response = await self._client_wrapper.httpx_client.request( + "plants", + method="POST", + json={ + "sunExposure": sun_exposure, + "plantedAt": planted_at, + "soilType": soil_type, + "commonName": common_name, + "wateringFrequency": watering_frequency, + "species": species, + "family": family, + "genus": genus, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PlantStrict, + parse_obj_as( + type_=PlantStrict, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_tree( + self, + *, + id: str, + planted_date: typing.Optional[dt.date] = OMIT, + tree_species: typing.Optional[str] = OMIT, + height_in_feet: typing.Optional[float] = OMIT, + tree_name: typing.Optional[str] = OMIT, + tree_description: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[TreeRecord]: + """ + Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + + Parameters + ---------- + id : str + Unique tree identifier. + + planted_date : typing.Optional[dt.date] + Date the tree was planted. + + tree_species : typing.Optional[str] + The species of tree. + + height_in_feet : typing.Optional[float] + Height of the tree in feet. + + tree_name : typing.Optional[str] + Display name of the tree. + + tree_description : typing.Optional[str] + A description of the tree. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TreeRecord] + Created tree + """ + _response = await self._client_wrapper.httpx_client.request( + "trees", + method="POST", + json={ + "plantedDate": planted_date, + "treeSpecies": tree_species, + "heightInFeet": height_in_feet, + "id": id, + "treeName": tree_name, + "treeDescription": tree_description, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TreeRecord, + parse_obj_as( + type_=TreeRecord, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/types/__init__.py b/seed/python-sdk/allof/no-custom-config/src/seed/types/__init__.py index 94c23dd12fd2..446f8959908d 100644 --- a/seed/python-sdk/allof/no-custom-config/src/seed/types/__init__.py +++ b/seed/python-sdk/allof/no-custom-config/src/seed/types/__init__.py @@ -18,12 +18,20 @@ from .organization import Organization from .paginated_result import PaginatedResult from .paging_cursors import PagingCursors + from .plant_base import PlantBase + from .plant_base_watering_frequency import PlantBaseWateringFrequency + from .plant_post_sun_exposure import PlantPostSunExposure + from .plant_strict import PlantStrict from .rule_create_request_execution_context import RuleCreateRequestExecutionContext from .rule_execution_context import RuleExecutionContext from .rule_response import RuleResponse from .rule_response_status import RuleResponseStatus from .rule_type import RuleType from .rule_type_search_response import RuleTypeSearchResponse + from .tree_base import TreeBase + from .tree_describable import TreeDescribable + from .tree_identifiable import TreeIdentifiable + from .tree_record import TreeRecord from .user import User from .user_search_response import UserSearchResponse _dynamic_imports: typing.Dict[str, str] = { @@ -39,12 +47,20 @@ "Organization": ".organization", "PaginatedResult": ".paginated_result", "PagingCursors": ".paging_cursors", + "PlantBase": ".plant_base", + "PlantBaseWateringFrequency": ".plant_base_watering_frequency", + "PlantPostSunExposure": ".plant_post_sun_exposure", + "PlantStrict": ".plant_strict", "RuleCreateRequestExecutionContext": ".rule_create_request_execution_context", "RuleExecutionContext": ".rule_execution_context", "RuleResponse": ".rule_response", "RuleResponseStatus": ".rule_response_status", "RuleType": ".rule_type", "RuleTypeSearchResponse": ".rule_type_search_response", + "TreeBase": ".tree_base", + "TreeDescribable": ".tree_describable", + "TreeIdentifiable": ".tree_identifiable", + "TreeRecord": ".tree_record", "User": ".user", "UserSearchResponse": ".user_search_response", } @@ -84,12 +100,20 @@ def __dir__(): "Organization", "PaginatedResult", "PagingCursors", + "PlantBase", + "PlantBaseWateringFrequency", + "PlantPostSunExposure", + "PlantStrict", "RuleCreateRequestExecutionContext", "RuleExecutionContext", "RuleResponse", "RuleResponseStatus", "RuleType", "RuleTypeSearchResponse", + "TreeBase", + "TreeDescribable", + "TreeIdentifiable", + "TreeRecord", "User", "UserSearchResponse", ] diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_base.py b/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_base.py new file mode 100644 index 000000000000..dea13b4c50e7 --- /dev/null +++ b/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_base.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata +from .plant_base_watering_frequency import PlantBaseWateringFrequency +from .plant_strict import PlantStrict + + +class PlantBase(PlantStrict): + common_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="commonName"), + pydantic.Field(alias="commonName", description="The common name of the plant."), + ] = None + watering_frequency: typing_extensions.Annotated[ + typing.Optional[PlantBaseWateringFrequency], + FieldMetadata(alias="wateringFrequency"), + pydantic.Field(alias="wateringFrequency"), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_base_watering_frequency.py b/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_base_watering_frequency.py new file mode 100644 index 000000000000..6dae335d41f6 --- /dev/null +++ b/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_base_watering_frequency.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +PlantBaseWateringFrequency = typing.Union[typing.Literal["daily", "weekly", "biweekly", "monthly"], typing.Any] diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_post_sun_exposure.py b/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_post_sun_exposure.py new file mode 100644 index 000000000000..c851b19c9292 --- /dev/null +++ b/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_post_sun_exposure.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +PlantPostSunExposure = typing.Union[typing.Literal["full", "partial", "shade"], typing.Any] diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_strict.py b/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_strict.py new file mode 100644 index 000000000000..4109c1823e60 --- /dev/null +++ b/seed/python-sdk/allof/no-custom-config/src/seed/types/plant_strict.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class PlantStrict(UniversalBaseModel): + species: str = pydantic.Field() + """ + The botanical species name. + """ + + family: str = pydantic.Field() + """ + The botanical family. + """ + + genus: str = pydantic.Field() + """ + The botanical genus. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_base.py b/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_base.py new file mode 100644 index 000000000000..1fbe323ea505 --- /dev/null +++ b/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_base.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata +from .tree_describable import TreeDescribable +from .tree_identifiable import TreeIdentifiable + + +class TreeBase(TreeIdentifiable, TreeDescribable): + tree_species: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="treeSpecies"), + pydantic.Field(alias="treeSpecies", description="The species of tree."), + ] = None + height_in_feet: typing_extensions.Annotated[ + typing.Optional[float], + FieldMetadata(alias="heightInFeet"), + pydantic.Field(alias="heightInFeet", description="Height of the tree in feet."), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_describable.py b/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_describable.py new file mode 100644 index 000000000000..651e62c8d3c2 --- /dev/null +++ b/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_describable.py @@ -0,0 +1,30 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class TreeDescribable(UniversalBaseModel): + tree_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="treeName"), + pydantic.Field(alias="treeName", description="Display name of the tree."), + ] = None + tree_description: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="treeDescription"), + pydantic.Field(alias="treeDescription", description="A description of the tree."), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_identifiable.py b/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_identifiable.py new file mode 100644 index 000000000000..690068db8c6f --- /dev/null +++ b/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_identifiable.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TreeIdentifiable(UniversalBaseModel): + id: str = pydantic.Field() + """ + Unique tree identifier. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_record.py b/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_record.py new file mode 100644 index 000000000000..21ea2af61dde --- /dev/null +++ b/seed/python-sdk/allof/no-custom-config/src/seed/types/tree_record.py @@ -0,0 +1,27 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata +from .tree_base import TreeBase + + +class TreeRecord(TreeBase): + planted_date: typing_extensions.Annotated[ + typing.Optional[dt.date], + FieldMetadata(alias="plantedDate"), + pydantic.Field(alias="plantedDate", description="Date the tree was planted."), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof/no-custom-config/tests/wire/test_.py b/seed/python-sdk/allof/no-custom-config/tests/wire/test_.py index 2f638bc05b10..21f3c9a6cb11 100644 --- a/seed/python-sdk/allof/no-custom-config/tests/wire/test_.py +++ b/seed/python-sdk/allof/no-custom-config/tests/wire/test_.py @@ -42,3 +42,26 @@ def test__get_organization() -> None: client = get_client(test_id) client.get_organization() verify_request_count(test_id, "GET", "/organizations", None, 1) + + +def test__create_plant() -> None: + """Test createPlant endpoint with WireMock""" + test_id = "create_plant.0" + client = get_client(test_id) + client.create_plant( + species="species", + family="family", + genus="genus", + sun_exposure="full", + ) + verify_request_count(test_id, "POST", "/plants", None, 1) + + +def test__create_tree() -> None: + """Test createTree endpoint with WireMock""" + test_id = "create_tree.0" + client = get_client(test_id) + client.create_tree( + id="id", + ) + verify_request_count(test_id, "POST", "/trees", None, 1) diff --git a/seed/python-sdk/allof/no-custom-config/wiremock/wiremock-mappings.json b/seed/python-sdk/allof/no-custom-config/wiremock/wiremock-mappings.json index bfeb998eabae..97061f28d6e6 100644 --- a/seed/python-sdk/allof/no-custom-config/wiremock/wiremock-mappings.json +++ b/seed/python-sdk/allof/no-custom-config/wiremock/wiremock-mappings.json @@ -133,9 +133,61 @@ } }, "postServeActions": [] + }, + { + "id": "fc35a3ba-6a37-4842-99e5-f58821955c31", + "name": "Create a plant (nested allOf with $ref chain) - default", + "request": { + "urlPathTemplate": "/plants", + "method": "POST" + }, + "response": { + "status": 200, + "body": "{\n \"species\": \"species\",\n \"family\": \"family\",\n \"genus\": \"genus\"\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "fc35a3ba-6a37-4842-99e5-f58821955c31", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "b78e388b-bdbb-4fab-a683-45f8819e89ec", + "name": "Create a tree (multiple nested $ref in parent allOf) - default", + "request": { + "urlPathTemplate": "/trees", + "method": "POST" + }, + "response": { + "status": 200, + "body": "{\n \"treeName\": \"treeName\",\n \"treeDescription\": \"treeDescription\",\n \"id\": \"id\",\n \"treeSpecies\": \"treeSpecies\",\n \"heightInFeet\": 1.1,\n \"plantedDate\": \"2023-01-15\"\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "b78e388b-bdbb-4fab-a683-45f8819e89ec", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } } ], "meta": { - "total": 5 + "total": 7 } } \ No newline at end of file diff --git a/seed/ts-sdk/allof/.fern/metadata.json b/seed/ts-sdk/allof/.fern/metadata.json index 5789995ae525..9ea68298004b 100644 --- a/seed/ts-sdk/allof/.fern/metadata.json +++ b/seed/ts-sdk/allof/.fern/metadata.json @@ -3,8 +3,7 @@ "generatorName": "fernapi/fern-typescript-sdk", "generatorVersion": "local", "originGitCommit": "DUMMY", - "invokedBy": "ci", + "invokedBy": "manual", "requestedVersion": "0.0.1", - "ciProvider": "github", "sdkVersion": "0.0.1" } diff --git a/seed/ts-sdk/allof/reference.md b/seed/ts-sdk/allof/reference.md index 6d75591df79d..8f817d3b578c 100644 --- a/seed/ts-sdk/allof/reference.md +++ b/seed/ts-sdk/allof/reference.md @@ -223,3 +223,136 @@ await client.getOrganization(); +
client.createPlant({ ...params }) -> SeedApi.PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.createPlant({ + species: "species", + family: "family", + genus: "genus", + sunExposure: "full" +}); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `SeedApi.PlantPost` + +
+
+ +
+
+ +**requestOptions:** `SeedApiClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ +
client.createTree({ ...params }) -> SeedApi.TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.createTree({ + id: "id" +}); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `SeedApi.TreeRecord` + +
+
+ +
+
+ +**requestOptions:** `SeedApiClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/ts-sdk/allof/snippet.json b/seed/ts-sdk/allof/snippet.json index 77c90740f6d5..45c355884f26 100644 --- a/seed/ts-sdk/allof/snippet.json +++ b/seed/ts-sdk/allof/snippet.json @@ -54,6 +54,28 @@ "type": "typescript", "client": "import { SeedApiClient } from \"@fern/allof\";\n\nconst client = new SeedApiClient;\nawait client.getOrganization();\n" } + }, + { + "id": { + "path": "/plants", + "method": "POST", + "identifier_override": "endpoint_.createPlant" + }, + "snippet": { + "type": "typescript", + "client": "import { SeedApiClient } from \"@fern/allof\";\n\nconst client = new SeedApiClient;\nawait client.createPlant({\n species: \"species\",\n family: \"family\",\n genus: \"genus\",\n sunExposure: \"full\"\n});\n" + } + }, + { + "id": { + "path": "/trees", + "method": "POST", + "identifier_override": "endpoint_.createTree" + }, + "snippet": { + "type": "typescript", + "client": "import { SeedApiClient } from \"@fern/allof\";\n\nconst client = new SeedApiClient;\nawait client.createTree({\n id: \"id\"\n});\n" + } } ], "types": {} diff --git a/seed/ts-sdk/allof/src/Client.ts b/seed/ts-sdk/allof/src/Client.ts index ba5afd81e1a0..ef645e7d693c 100644 --- a/seed/ts-sdk/allof/src/Client.ts +++ b/seed/ts-sdk/allof/src/Client.ts @@ -275,6 +275,123 @@ export class SeedApiClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/organizations"); } + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + * + * @param {SeedApi.PlantPost} request + * @param {SeedApiClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.createPlant({ + * species: "species", + * family: "family", + * genus: "genus", + * sunExposure: "full" + * }) + */ + public createPlant( + request: SeedApi.PlantPost, + requestOptions?: SeedApiClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__createPlant(request, requestOptions)); + } + + private async __createPlant( + request: SeedApi.PlantPost, + requestOptions?: SeedApiClient.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)) ?? + environments.SeedApiEnvironment.Default, + "plants", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + requestType: "json", + body: request, + 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 SeedApi.PlantStrict, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.SeedApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/plants"); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + * + * @param {SeedApi.TreeRecord} request + * @param {SeedApiClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.createTree({ + * id: "id" + * }) + */ + public createTree( + request: SeedApi.TreeRecord, + requestOptions?: SeedApiClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__createTree(request, requestOptions)); + } + + private async __createTree( + request: SeedApi.TreeRecord, + requestOptions?: SeedApiClient.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)) ?? + environments.SeedApiEnvironment.Default, + "trees", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + requestType: "json", + body: request, + 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 SeedApi.TreeRecord, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.SeedApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/trees"); + } + /** * Make a passthrough request using the SDK's configured auth, retry, logging, etc. * This is useful for making requests to endpoints not yet supported in the SDK. diff --git a/seed/ts-sdk/allof/src/api/client/requests/PlantPost.ts b/seed/ts-sdk/allof/src/api/client/requests/PlantPost.ts new file mode 100644 index 000000000000..2760d18cf69b --- /dev/null +++ b/seed/ts-sdk/allof/src/api/client/requests/PlantPost.ts @@ -0,0 +1,31 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as SeedApi from "../../index.js"; + +/** + * @example + * { + * species: "species", + * family: "family", + * genus: "genus", + * sunExposure: "full" + * } + */ +export interface PlantPost extends SeedApi.PlantBase { + /** Required sun exposure level. */ + sunExposure: PlantPost.SunExposure; + /** Date the plant was planted. */ + plantedAt?: string; + /** Preferred soil type. */ + soilType?: string; +} + +export namespace PlantPost { + /** Required sun exposure level. */ + export const SunExposure = { + Full: "full", + Partial: "partial", + Shade: "shade", + } as const; + export type SunExposure = (typeof SunExposure)[keyof typeof SunExposure]; +} diff --git a/seed/ts-sdk/allof/src/api/client/requests/index.ts b/seed/ts-sdk/allof/src/api/client/requests/index.ts index c8c22b703233..8c029b7dd67c 100644 --- a/seed/ts-sdk/allof/src/api/client/requests/index.ts +++ b/seed/ts-sdk/allof/src/api/client/requests/index.ts @@ -1,2 +1,3 @@ +export { PlantPost } from "./PlantPost.js"; export { RuleCreateRequest } from "./RuleCreateRequest.js"; export type { SearchRuleTypesRequest } from "./SearchRuleTypesRequest.js"; diff --git a/seed/ts-sdk/allof/src/api/types/PlantBase.ts b/seed/ts-sdk/allof/src/api/types/PlantBase.ts new file mode 100644 index 000000000000..71c063433a85 --- /dev/null +++ b/seed/ts-sdk/allof/src/api/types/PlantBase.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as SeedApi from "../index.js"; + +export interface PlantBase extends SeedApi.PlantStrict { + /** The common name of the plant. */ + commonName?: string | undefined; + wateringFrequency?: PlantBase.WateringFrequency | undefined; +} + +export namespace PlantBase { + export const WateringFrequency = { + Daily: "daily", + Weekly: "weekly", + Biweekly: "biweekly", + Monthly: "monthly", + } as const; + export type WateringFrequency = (typeof WateringFrequency)[keyof typeof WateringFrequency]; +} diff --git a/seed/ts-sdk/allof/src/api/types/PlantStrict.ts b/seed/ts-sdk/allof/src/api/types/PlantStrict.ts new file mode 100644 index 000000000000..1c607c5fbd30 --- /dev/null +++ b/seed/ts-sdk/allof/src/api/types/PlantStrict.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface PlantStrict { + /** The botanical species name. */ + species: string; + /** The botanical family. */ + family: string; + /** The botanical genus. */ + genus: string; +} diff --git a/seed/ts-sdk/allof/src/api/types/TreeBase.ts b/seed/ts-sdk/allof/src/api/types/TreeBase.ts new file mode 100644 index 000000000000..e42af0eedbae --- /dev/null +++ b/seed/ts-sdk/allof/src/api/types/TreeBase.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as SeedApi from "../index.js"; + +export interface TreeBase extends SeedApi.TreeIdentifiable, SeedApi.TreeDescribable { + /** The species of tree. */ + treeSpecies?: string | undefined; + /** Height of the tree in feet. */ + heightInFeet?: number | undefined; +} diff --git a/seed/ts-sdk/allof/src/api/types/TreeDescribable.ts b/seed/ts-sdk/allof/src/api/types/TreeDescribable.ts new file mode 100644 index 000000000000..607ab65f3516 --- /dev/null +++ b/seed/ts-sdk/allof/src/api/types/TreeDescribable.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TreeDescribable { + /** Display name of the tree. */ + treeName?: string | undefined; + /** A description of the tree. */ + treeDescription?: string | undefined; +} diff --git a/seed/ts-sdk/allof/src/api/types/TreeIdentifiable.ts b/seed/ts-sdk/allof/src/api/types/TreeIdentifiable.ts new file mode 100644 index 000000000000..9f6f9c68c699 --- /dev/null +++ b/seed/ts-sdk/allof/src/api/types/TreeIdentifiable.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TreeIdentifiable { + /** Unique tree identifier. */ + id: string; +} diff --git a/seed/ts-sdk/allof/src/api/types/TreeRecord.ts b/seed/ts-sdk/allof/src/api/types/TreeRecord.ts new file mode 100644 index 000000000000..2836a2300342 --- /dev/null +++ b/seed/ts-sdk/allof/src/api/types/TreeRecord.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as SeedApi from "../index.js"; + +export interface TreeRecord extends SeedApi.TreeBase { + /** Date the tree was planted. */ + plantedDate?: string | undefined; +} diff --git a/seed/ts-sdk/allof/src/api/types/index.ts b/seed/ts-sdk/allof/src/api/types/index.ts index ae8a133ce81f..fcceebd8605a 100644 --- a/seed/ts-sdk/allof/src/api/types/index.ts +++ b/seed/ts-sdk/allof/src/api/types/index.ts @@ -7,9 +7,15 @@ export * from "./Identifiable.js"; export * from "./Organization.js"; export * from "./PaginatedResult.js"; export * from "./PagingCursors.js"; +export * from "./PlantBase.js"; +export * from "./PlantStrict.js"; export * from "./RuleExecutionContext.js"; export * from "./RuleResponse.js"; export * from "./RuleType.js"; export * from "./RuleTypeSearchResponse.js"; +export * from "./TreeBase.js"; +export * from "./TreeDescribable.js"; +export * from "./TreeIdentifiable.js"; +export * from "./TreeRecord.js"; export * from "./User.js"; export * from "./UserSearchResponse.js"; diff --git a/seed/ts-sdk/allof/tests/wire/main.test.ts b/seed/ts-sdk/allof/tests/wire/main.test.ts index 6af233ca61ca..0be7d684ecea 100644 --- a/seed/ts-sdk/allof/tests/wire/main.test.ts +++ b/seed/ts-sdk/allof/tests/wire/main.test.ts @@ -88,4 +88,56 @@ describe("SeedApiClient", () => { const response = await client.getOrganization(); expect(response).toEqual(rawResponseBody); }); + + test("createPlant", async () => { + const server = mockServerPool.createServer(); + const client = new SeedApiClient({ maxRetries: 0, environment: server.baseUrl }); + const rawRequestBody = { species: "species", family: "family", genus: "genus", sunExposure: "full" }; + const rawResponseBody = { species: "species", family: "family", genus: "genus" }; + + server + .mockEndpoint() + .post("/plants") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.createPlant({ + species: "species", + family: "family", + genus: "genus", + sunExposure: "full", + }); + expect(response).toEqual(rawResponseBody); + }); + + test("createTree", async () => { + const server = mockServerPool.createServer(); + const client = new SeedApiClient({ maxRetries: 0, environment: server.baseUrl }); + const rawRequestBody = { id: "id" }; + const rawResponseBody = { + treeName: "treeName", + treeDescription: "treeDescription", + id: "id", + treeSpecies: "treeSpecies", + heightInFeet: 1.1, + plantedDate: "2023-01-15", + }; + + server + .mockEndpoint() + .post("/trees") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.createTree({ + id: "id", + }); + expect(response).toEqual(rawResponseBody); + }); }); diff --git a/test-definitions/fern/apis/allof/openapi.yml b/test-definitions/fern/apis/allof/openapi.yml index b8564ac0700a..bfcc40d1345c 100644 --- a/test-definitions/fern/apis/allof/openapi.yml +++ b/test-definitions/fern/apis/allof/openapi.yml @@ -5,7 +5,7 @@ info: description: > Exercises allOf schema composition patterns, covering array items narrowing, primitive constraint narrowing, required propagation, metadata inheritance, - and multi-parent property merging. + multi-parent property merging, and nested $ref resolution. servers: - url: https://api.example.com @@ -82,6 +82,49 @@ paths: schema: $ref: "#/components/schemas/Organization" + /plants: + post: + operationId: createPlant + summary: Create a plant (nested allOf with $ref chain) + description: > + Tests three-level allOf chain where a parent schema itself uses allOf + with $ref elements. The grandparent's properties must be resolved + through the nested $ref. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PlantPost" + responses: + "200": + description: Created plant + content: + application/json: + schema: + $ref: "#/components/schemas/PlantStrict" + + /trees: + post: + operationId: createTree + summary: Create a tree (multiple nested $ref in parent allOf) + description: > + Tests that when a parent's allOf contains multiple $ref entries, + all of them are resolved and their properties merged. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TreeRecord" + responses: + "200": + description: Created tree + content: + application/json: + schema: + $ref: "#/components/schemas/TreeRecord" + components: schemas: # ----------------------------------------------------------------------- @@ -348,3 +391,125 @@ components: properties: name: type: string + + # ----------------------------------------------------------------------- + # Case 7: Three-level allOf chain with nested $ref (BigCommerce pattern) + # + # PlantPost -> PlantBase -> PlantStrict, where PlantBase uses allOf + # with a $ref to PlantStrict. The v3 parser must recursively resolve + # the nested $ref to include grandparent properties. + # + # Expected: PlantPost includes all properties from PlantStrict, + # PlantBase sibling, and its own inline child. + # ----------------------------------------------------------------------- + PlantStrict: + type: object + required: + - species + - family + - genus + properties: + species: + type: string + description: The botanical species name. + family: + type: string + description: The botanical family. + genus: + type: string + description: The botanical genus. + + PlantBase: + allOf: + - $ref: "#/components/schemas/PlantStrict" + - type: object + properties: + commonName: + type: string + description: The common name of the plant. + wateringFrequency: + type: string + enum: + - daily + - weekly + - biweekly + - monthly + + PlantPost: + allOf: + - $ref: "#/components/schemas/PlantBase" + - required: + - species + - family + - genus + - commonName + - wateringFrequency + - sunExposure + properties: + sunExposure: + type: string + enum: + - full + - partial + - shade + description: Required sun exposure level. + plantedAt: + type: string + format: date + description: Date the plant was planted. + soilType: + type: string + description: Preferred soil type. + + # ----------------------------------------------------------------------- + # Case 8: Parent allOf with multiple $ref entries + # + # TreeBase uses allOf with two $ref entries (Identifiable + Describable) + # plus an inline schema. TreeRecord extends TreeBase and adds its own + # required + properties. All three $ref targets must be resolved. + # ----------------------------------------------------------------------- + TreeIdentifiable: + type: object + required: + - id + properties: + id: + type: string + format: uuid + description: Unique tree identifier. + + TreeDescribable: + type: object + properties: + treeName: + type: string + description: Display name of the tree. + treeDescription: + type: string + description: A description of the tree. + + TreeBase: + allOf: + - $ref: "#/components/schemas/TreeIdentifiable" + - $ref: "#/components/schemas/TreeDescribable" + - type: object + properties: + treeSpecies: + type: string + description: The species of tree. + heightInFeet: + type: number + description: Height of the tree in feet. + + TreeRecord: + allOf: + - $ref: "#/components/schemas/TreeBase" + - required: + - id + - treeName + - treeSpecies + properties: + plantedDate: + type: string + format: date + description: Date the tree was planted. From b4a28a0425ed8398fbfe7a02bc04689f560bffc1 Mon Sep 17 00:00:00 2001 From: jsklan <100491078+jsklan@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:35:56 -0400 Subject: [PATCH 05/14] fix(cli-generator): strip fixture-dependent inline tests from vendored cli-sdk (#16195) The daily cli-sdk sync copies inline #[cfg(test)] modules that reference fixture specs via include_str!("../../cli//...") which are not synced. This breaks cargo test in both the vendored SDK and every generated CLI. - Add generators/cli/scripts/strip-fixture-tests.py: a Rust-syntax-aware script that removes #[cfg(test)] blocks containing fixture refs while preserving non-fixture tests. Handles multi-line raw strings, block comments, char literals, and transitive dependency tracking. - Update sync-sdk.sh to call strip-fixture-tests.py as a post-rsync step so future syncs stay clean automatically. - Remove 346 lines of fixture-dependent test code from 5 files: openapi/parser.rs, openapi/overlay.rs, openapi/app.rs, graphql/parser.rs, graphql/binding.rs (entire module removed). Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../unreleased/strip-fixture-tests.yml | 2 + generators/cli/scripts/strip-fixture-tests.py | 484 ++++++++++++++++++ generators/cli/scripts/sync-sdk.sh | 9 + generators/cli/sdk/src/graphql/binding.rs | 57 --- generators/cli/sdk/src/graphql/parser.rs | 17 - generators/cli/sdk/src/openapi/app.rs | 11 - generators/cli/sdk/src/openapi/overlay.rs | 101 ---- generators/cli/sdk/src/openapi/parser.rs | 160 ------ 8 files changed, 495 insertions(+), 346 deletions(-) create mode 100644 generators/cli/changes/unreleased/strip-fixture-tests.yml create mode 100644 generators/cli/scripts/strip-fixture-tests.py diff --git a/generators/cli/changes/unreleased/strip-fixture-tests.yml b/generators/cli/changes/unreleased/strip-fixture-tests.yml new file mode 100644 index 000000000000..e56c469d19a7 --- /dev/null +++ b/generators/cli/changes/unreleased/strip-fixture-tests.yml @@ -0,0 +1,2 @@ +- summary: Strip fixture-dependent inline tests from vendored cli-sdk so `cargo test` passes in both the SDK and generated CLIs + type: fix diff --git a/generators/cli/scripts/strip-fixture-tests.py b/generators/cli/scripts/strip-fixture-tests.py new file mode 100644 index 000000000000..7dda5097fa4f --- /dev/null +++ b/generators/cli/scripts/strip-fixture-tests.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +"""Strip fixture-dependent inline test code from vendored cli-sdk sources. + +Usage: + python3 generators/cli/scripts/strip-fixture-tests.py + +Removes #[test] functions, #[cfg(test)] helper items, and entire +#[cfg(test)] modules that reference fixture files via +include_str!("../../cli/...") paths. Those fixtures exist in the source +cli-sdk repo but are NOT synced into the vendored copy, so they break +`cargo test` compilation. + +Non-fixture tests are preserved. If stripping leaves a #[cfg(test)] +module with no remaining #[test] functions, the entire module is removed. + +Called by sync-sdk.sh after rsyncing cli-sdk src/ into +generators/cli/sdk/src/. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +FIXTURE_RE = re.compile(r'include_str!\s*\(\s*"[^"]*\.\./\.\./cli/') + + +# --------------------------------------------------------------------------- +# Rust lexer — character-by-character scanner that properly handles +# strings (regular + raw), char literals, line/block comments. +# --------------------------------------------------------------------------- + +class RustScanner: + """Scans Rust source, tracking brace depth and string/comment state.""" + + def __init__(self, lines: list[str], start_line: int = 0, start_col: int = 0): + self.lines = lines + self.i = start_line + self.j = start_col + self.depth = 0 + self._in_string = False + self._in_char = False + self._escape = False + + def _advance_line(self) -> bool: + self.i += 1 + self.j = 0 + return self.i < len(self.lines) + + def find_matching_brace(self) -> int: + """Scan forward until depth returns to 0 after going positive. + Returns the line index of the matching closing brace.""" + found_open = False + while self.i < len(self.lines): + line = self.lines[self.i] + while self.j < len(line): + ch = line[self.j] + + if self._escape: + self._escape = False + self.j += 1 + continue + + if self._in_char: + if ch == '\\': + self._escape = True + elif ch == "'": + self._in_char = False + self.j += 1 + continue + + if self._in_string: + if ch == '\\': + self._escape = True + elif ch == '"': + self._in_string = False + self.j += 1 + continue + + # Outside strings/chars + if ch == '\\': + self._escape = True + self.j += 1 + continue + + # Line comment + if ch == '/' and self.j + 1 < len(line) and line[self.j + 1] == '/': + break # skip rest of line + + # Block comment + if ch == '/' and self.j + 1 < len(line) and line[self.j + 1] == '*': + self.j += 2 + while True: + while self.j < len(self.lines[self.i]): + if (self.lines[self.i][self.j] == '*' + and self.j + 1 < len(self.lines[self.i]) + and self.lines[self.i][self.j + 1] == '/'): + self.j += 2 + break + self.j += 1 + else: + if not self._advance_line(): + return len(self.lines) - 1 + continue + break + line = self.lines[self.i] + continue + + # Raw string r#"..."# + if ch == '"': + raw_hashes = 0 + k = self.j - 1 + while k >= 0 and line[k] == '#': + raw_hashes += 1 + k -= 1 + if k >= 0 and line[k] == 'r': + close_pat = '"' + '#' * raw_hashes + self.j += 1 + while True: + pos = self.lines[self.i].find(close_pat, self.j) + if pos >= 0: + self.j = pos + len(close_pat) + break + if not self._advance_line(): + return len(self.lines) - 1 + line = self.lines[self.i] + continue + self._in_string = True + self.j += 1 + continue + + # Char literal vs lifetime + if ch == "'" and self.j + 1 < len(line): + rest = line[self.j + 1: self.j + 5] + if rest and (rest[0] == '\\' or "'" in rest[1:4]): + self._in_char = True + self.j += 1 + continue + self.j += 1 + continue + + if ch == '{': + self.depth += 1 + found_open = True + elif ch == '}': + self.depth -= 1 + if found_open and self.depth == 0: + return self.i + + self.j += 1 + + if not self._advance_line(): + break + return len(self.lines) - 1 + + def find_item_end(self, max_line: int) -> int: + """Find end of a Rust item: first `}` at depth 0 (if braces seen) + or first `;` at depth 0 (if no braces yet). Respects strings.""" + found_brace = False + while self.i <= max_line: + line = self.lines[self.i] + while self.j < len(line): + ch = line[self.j] + + if self._escape: + self._escape = False + self.j += 1 + continue + + if self._in_char: + if ch == '\\': + self._escape = True + elif ch == "'": + self._in_char = False + self.j += 1 + continue + + if self._in_string: + if ch == '\\': + self._escape = True + elif ch == '"': + self._in_string = False + self.j += 1 + continue + + if ch == '\\': + self._escape = True + self.j += 1 + continue + + if ch == '/' and self.j + 1 < len(line) and line[self.j + 1] == '/': + break + + if ch == '/' and self.j + 1 < len(line) and line[self.j + 1] == '*': + self.j += 2 + while True: + while self.j < len(self.lines[self.i]): + if (self.lines[self.i][self.j] == '*' + and self.j + 1 < len(self.lines[self.i]) + and self.lines[self.i][self.j + 1] == '/'): + self.j += 2 + break + self.j += 1 + else: + self.i += 1 + self.j = 0 + if self.i > max_line: + return max_line + continue + break + line = self.lines[self.i] + continue + + if ch == '"': + raw_hashes = 0 + k = self.j - 1 + while k >= 0 and line[k] == '#': + raw_hashes += 1 + k -= 1 + if k >= 0 and line[k] == 'r': + close_pat = '"' + '#' * raw_hashes + self.j += 1 + while True: + pos = self.lines[self.i].find(close_pat, self.j) + if pos >= 0: + self.j = pos + len(close_pat) + break + self.i += 1 + self.j = 0 + if self.i > max_line: + return max_line + line = self.lines[self.i] + continue + self._in_string = True + self.j += 1 + continue + + if ch == "'" and self.j + 1 < len(line): + rest = line[self.j + 1: self.j + 5] + if rest and (rest[0] == '\\' or "'" in rest[1:4]): + self._in_char = True + self.j += 1 + continue + self.j += 1 + continue + + if ch == '{': + self.depth += 1 + found_brace = True + elif ch == '}': + self.depth -= 1 + if found_brace and self.depth == 0: + return self.i + elif ch == ';' and self.depth == 0 and not found_brace: + return self.i + + self.j += 1 + + self.i += 1 + self.j = 0 + return max_line + + +# --------------------------------------------------------------------------- +# Item extraction & processing +# --------------------------------------------------------------------------- + +def _extract_items(lines: list[str], body_start: int, body_end: int) -> list[dict]: + """Extract top-level items from within a #[cfg(test)] module body.""" + items = [] + i = body_start + while i <= body_end: + stripped = lines[i].strip() + if not stripped or stripped.startswith("//"): + i += 1 + continue + + # Walk back to include preceding doc-comments and attributes. + item_start = i + while item_start > body_start: + prev = lines[item_start - 1].strip() + if prev.startswith("//") or prev.startswith("#["): + item_start -= 1 + else: + break + + # Find item end using the scanner. + scanner = RustScanner(lines, i, 0) + item_end = scanner.find_item_end(body_end) + + text = "\n".join(lines[item_start: item_end + 1]) + is_test = "#[test]" in text + has_fixture = bool(FIXTURE_RE.search(text)) + name_match = re.search( + r'\b(?:fn|const|static|struct|enum|mod|type)\s+(\w+)', text + ) + name = name_match.group(1) if name_match else "" + + items.append({ + "start": item_start, + "end": item_end, + "text": text, + "name": name, + "is_test": is_test, + "has_fixture_ref": has_fixture, + }) + i = item_end + 1 + + return items + + +def process_file(path: Path) -> bool: + """Process a single .rs file. Returns True if the file was modified.""" + content = path.read_text() + if '../../cli/' not in content: + return False + + lines = content.split('\n') + regions_to_remove: list[tuple[int, int]] = [] + + i = 0 + while i < len(lines): + stripped = lines[i].strip() + + if stripped != '#[cfg(test)]': + i += 1 + continue + + cfg_line = i + + # Find the next non-blank, non-comment, non-attribute line + j = i + 1 + while j < len(lines) and ( + not lines[j].strip() + or lines[j].strip().startswith("//") + or lines[j].strip().startswith("#[") + ): + j += 1 + + if j >= len(lines): + i += 1 + continue + + next_line = lines[j].strip() + + # Case 1: #[cfg(test)] mod ... { } + if re.match(r'(pub\s+)?mod\s+\w+', next_line): + scanner = RustScanner(lines, j, 0) + mod_close = scanner.find_matching_brace() + + mod_text = "\n".join(lines[cfg_line: mod_close + 1]) + if not FIXTURE_RE.search(mod_text): + i = mod_close + 1 + continue + + # Module body = lines between opening { and closing } + # Find the { on the mod line + brace_col = lines[j].index('{') if '{' in lines[j] else -1 + if brace_col < 0: + i = mod_close + 1 + continue + body_start = j + 1 + body_end = mod_close - 1 + + if body_start > body_end: + regions_to_remove.append((cfg_line, mod_close)) + i = mod_close + 1 + continue + + items = _extract_items(lines, body_start, body_end) + + # Transitive closure: propagate fixture-dependency. + changed = True + while changed: + changed = False + tainted = { + it["name"] for it in items + if it["has_fixture_ref"] and it["name"] + } + for it in items: + if it["has_fixture_ref"]: + continue + for tn in tainted: + if tn and tn in it["text"]: + it["has_fixture_ref"] = True + changed = True + break + + items_to_remove = [it for it in items if it["has_fixture_ref"]] + if not items_to_remove: + i = mod_close + 1 + continue + + remaining_tests = [ + it for it in items + if it["is_test"] and not it["has_fixture_ref"] + ] + remaining_non_use = [ + it for it in items + if not it["has_fixture_ref"] + and not it["text"].strip().startswith("use ") + ] + + if not remaining_tests and not remaining_non_use: + # Remove entire module + start = cfg_line + while start > 0: + prev = lines[start - 1].strip() + if not prev or prev.startswith("//"): + start -= 1 + else: + break + regions_to_remove.append((start, mod_close)) + else: + for it in items_to_remove: + start = it["start"] + while start > body_start and not lines[start - 1].strip(): + start -= 1 + if start < it["start"]: + start += 1 + regions_to_remove.append((start, it["end"])) + + i = mod_close + 1 + continue + + # Case 2: #[cfg(test)] on a single item + else: + scanner = RustScanner(lines, j, 0) + item_end = scanner.find_item_end(len(lines) - 1) + item_text = "\n".join(lines[cfg_line: item_end + 1]) + if FIXTURE_RE.search(item_text): + regions_to_remove.append((cfg_line, item_end)) + i = item_end + 1 + continue + + if not regions_to_remove: + return False + + # Remove from bottom to top + regions_to_remove.sort(key=lambda r: r[0], reverse=True) + for start, end in regions_to_remove: + del lines[start: end + 1] + if 0 < start < len(lines): + if not lines[start - 1].strip() and not lines[start].strip(): + del lines[start] + + # Ensure single trailing newline + while lines and not lines[-1].strip(): + lines.pop() + lines.append("") + + new_content = "\n".join(lines) + if new_content != content: + path.write_text(new_content) + return True + return False + + +def main() -> None: + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + src_dir = Path(sys.argv[1]) + if not src_dir.is_dir(): + print(f"Error: {src_dir} is not a directory", file=sys.stderr) + sys.exit(1) + + modified = [] + for rs_file in sorted(src_dir.rglob("*.rs")): + if process_file(rs_file): + modified.append(rs_file) + + if modified: + print(f"Stripped fixture-dependent tests from {len(modified)} file(s):") + for f in modified: + print(f" {f}") + else: + print("No fixture-dependent tests found.") + + +if __name__ == "__main__": + main() diff --git a/generators/cli/scripts/sync-sdk.sh b/generators/cli/scripts/sync-sdk.sh index 887090ffd44d..d98474d99b9f 100755 --- a/generators/cli/scripts/sync-sdk.sh +++ b/generators/cli/scripts/sync-sdk.sh @@ -59,6 +59,15 @@ if [[ -d "$SDK_DIR/tests" ]]; then rm -rf "$SDK_DIR/tests" fi +# --------------------------------------------------------------------------- +# 1b. Strip fixture-dependent inline tests from src/ +# --------------------------------------------------------------------------- +# cli-sdk's #[cfg(test)] modules reference fixture specs via +# include_str!("../../cli//...") that are NOT synced. Removing them +# keeps `cargo test` clean in both the vendored SDK and generated CLIs. +echo "--- Stripping fixture-dependent inline tests from src/ ..." +python3 "$SCRIPT_DIR/strip-fixture-tests.py" "$SDK_DIR/src/" + # --------------------------------------------------------------------------- # 2. Project Cargo.toml (not a naive copy — workspace → single-package) # --------------------------------------------------------------------------- diff --git a/generators/cli/sdk/src/graphql/binding.rs b/generators/cli/sdk/src/graphql/binding.rs index b74d78f58946..1551cb08c203 100644 --- a/generators/cli/sdk/src/graphql/binding.rs +++ b/generators/cli/sdk/src/graphql/binding.rs @@ -384,60 +384,3 @@ impl Binding for GraphqlBinding { } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::binding::Binding; - - // A compact GraphQL introspection schema (the graphql-fixture surface) - // with two top-level resources: `node` and `filtered-node`. - const FIXTURE_SCHEMA: &str = include_str!("../../cli/graphql-fixture/schema.json"); - - fn prepared_binding(prefix: Option<&str>) -> GraphqlBinding { - let mut b = GraphqlBinding::new() - .spec(FIXTURE_SCHEMA) - .endpoint("http://localhost/graphql"); - if let Some(p) = prefix { - b = b.under(p); - } - b.set_cli_name("fixture"); - b - } - - #[test] - fn without_prefix_resources_are_top_level() { - let cmd = prepared_binding(None).build_command().unwrap(); - let names: Vec<_> = cmd.get_subcommands().map(|c| c.get_name()).collect(); - assert!(names.contains(&"node"), "expected `node` at root, got {names:?}"); - assert!( - !names.contains(&"graphql"), - "did not expect a `graphql` wrapper without .under(), got {names:?}" - ); - } - - #[test] - fn under_prefix_nests_all_resources() { - let cmd = prepared_binding(Some("graphql")).build_command().unwrap(); - let names: Vec<_> = cmd.get_subcommands().map(|c| c.get_name()).collect(); - assert!( - names.contains(&"graphql"), - "expected a top-level `graphql` wrapper, got {names:?}" - ); - assert!( - !names.contains(&"node"), - "resources should be nested under `graphql`, not at root: {names:?}" - ); - - // The original resources live one level down, under the wrapper. - let wrapper = cmd - .get_subcommands() - .find(|c| c.get_name() == "graphql") - .expect("graphql wrapper present"); - let nested: Vec<_> = wrapper.get_subcommands().map(|c| c.get_name()).collect(); - assert!( - nested.contains(&"node") && nested.contains(&"filtered-node"), - "expected node/filtered-node nested under graphql, got {nested:?}" - ); - } -} diff --git a/generators/cli/sdk/src/graphql/parser.rs b/generators/cli/sdk/src/graphql/parser.rs index 1318e44ab9ec..2b956c4bbe08 100644 --- a/generators/cli/sdk/src/graphql/parser.rs +++ b/generators/cli/sdk/src/graphql/parser.rs @@ -971,21 +971,4 @@ mod tests { ); } - #[test] - fn test_load_linear_schema() { - let json = include_str!("../../cli/linear/schema.json"); - let doc = load_graphql_schema(json, "linear", "https://api.linear.app/graphql").unwrap(); - assert_eq!(doc.name, "linear"); - - let issue = doc.resources.get("issue").expect("issue resource missing"); - assert!(issue.methods.contains_key("get"), "missing issue.get"); - assert!(issue.methods.contains_key("list"), "missing issue.list"); - assert!(issue.methods.contains_key("create"), "missing issue.create"); - - assert!( - doc.resources.len() > 20, - "expected >20 resources, got {}", - doc.resources.len() - ); - } } diff --git a/generators/cli/sdk/src/openapi/app.rs b/generators/cli/sdk/src/openapi/app.rs index 8daf8441732d..45c6dc36b04b 100644 --- a/generators/cli/sdk/src/openapi/app.rs +++ b/generators/cli/sdk/src/openapi/app.rs @@ -2948,17 +2948,6 @@ mod tests { ); } - #[test] - fn test_load_spec_via_cli_app() { - let yaml = include_str!("../../cli/box/openapi.yaml"); - let app = CliApp::new("box").spec(yaml); - assert_eq!(app.name, "box"); - // Verify the spec can be parsed via build_doc - let doc = app.build_doc().unwrap(); - assert_eq!(doc.name, "box"); - assert!(doc.resources.len() >= 14); - } - // ------------------------------------------------------------------ // CliApp::idempotency_header_env — generator-side env-var wiring for // FER-9852, implemented in cli-sdk for FER-9864 P1. Verifies the diff --git a/generators/cli/sdk/src/openapi/overlay.rs b/generators/cli/sdk/src/openapi/overlay.rs index 85659b5da950..2cac9fbb3d09 100644 --- a/generators/cli/sdk/src/openapi/overlay.rs +++ b/generators/cli/sdk/src/openapi/overlay.rs @@ -1830,105 +1830,4 @@ actions: // Item 5: Integration test — apply overlay to fixture spec, verify result // ----------------------------------------------------------------------- - #[test] - fn test_overlay_on_fixture_spec() { - let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); - let overlay = r#" -overlay: "1.0.0" -info: - title: fixture-overlay - version: "1.0.0" -actions: - - target: "$.info" - update: - description: "Modified by overlay" - - target: "$.paths['/users'].get" - update: - x-fern-sdk-method-name: listAllUsers - - target: "$.paths['/users'].get.parameters" - update: - name: offset - in: query - schema: - type: integer - - target: "$.paths['/files/{file_id}/thumbnail']" - remove: true -"#; - let result = - apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); - let doc: serde_json::Value = serde_yaml::from_str(&result).unwrap(); - - // Verify info.description was set - assert_eq!(doc["info"]["description"], "Modified by overlay"); - - // Verify method rename - assert_eq!( - doc["paths"]["/users"]["get"]["x-fern-sdk-method-name"], - "listAllUsers" - ); - - // Verify array append (new parameter added) - let params = doc["paths"]["/users"]["get"]["parameters"] - .as_array() - .unwrap(); - let has_offset = params.iter().any(|p| p["name"] == "offset"); - assert!(has_offset, "offset param should be appended: {params:?}"); - - // Verify remove - assert!( - doc["paths"]["/files/{file_id}/thumbnail"].is_null(), - "thumbnail path should be removed" - ); - - // Verify untouched paths still exist - assert!( - !doc["paths"]["/files/{file_id}"].is_null(), - "other file paths should remain" - ); - } - - #[test] - fn test_overlay_on_fixture_spec_builds_cli_app() { - use crate::openapi::CliApp; - - let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); - let overlay = r#" -overlay: "1.0.0" -info: - title: fixture-overlay - version: "1.0.0" -actions: - - target: "$.paths['/files/{file_id}/thumbnail']" - remove: true -"#; - - let app = CliApp::new("overlay-fixture") - .spec(spec) - .overlay(overlay); - let doc = app.build_doc().unwrap(); - - // files and folders groups should still exist - assert!(doc.resources.contains_key("files"), "files group missing"); - assert!(doc.resources.contains_key("folders"), "folders group missing"); - assert!(doc.resources.contains_key("users"), "users group missing"); - - // getThumbnail should be gone from the files resource - let files = &doc.resources["files"]; - assert!( - !files.methods.contains_key("getThumbnail"), - "getThumbnail should be removed: {:?}", - files.methods.keys().collect::>() - ); - // Other file operations should still exist - assert!( - files.methods.contains_key("get"), - "get should remain: {:?}", - files.methods.keys().collect::>() - ); - assert!( - files.methods.contains_key("update"), - "update should remain: {:?}", - files.methods.keys().collect::>() - ); - } } diff --git a/generators/cli/sdk/src/openapi/parser.rs b/generators/cli/sdk/src/openapi/parser.rs index 97bd10971431..0aed0a720b20 100644 --- a/generators/cli/sdk/src/openapi/parser.rs +++ b/generators/cli/sdk/src/openapi/parser.rs @@ -4816,69 +4816,6 @@ paths: assert_eq!(users.root_url, "https://api.example.com"); } - #[test] - fn test_box_upload_operations_use_upload_server() { - let yaml = include_str!("../../cli/box/openapi.yaml"); - let doc = load_openapi_spec(yaml, "box").unwrap(); - // At least one resource must have methods that route to upload.box.com - let upload_methods: Vec<_> = doc.resources.values() - .flat_map(|r| r.methods.values()) - .filter(|m| m.root_url.contains("upload.box.com")) - .collect(); - assert!( - !upload_methods.is_empty(), - "expected at least one method routing to upload.box.com, found none" - ); - } - - #[test] - fn test_load_box_spec() { - let yaml = include_str!("../../cli/box/openapi.yaml"); - let doc = load_openapi_spec(yaml, "box").unwrap(); - assert_eq!(doc.name, "box"); - assert_eq!(doc.root_url, "https://api.box.com/2.0"); - - // Box spec has 73 top-level resource groups: 72 with x-fern-sdk-group-name, - // plus one ("metadata taxonomies") surfaced by the tag-based fallback. - assert_eq!(doc.resources.len(), 73); - - // Check specific groups exist - assert!(doc.resources.contains_key("files")); - assert!(doc.resources.contains_key("folders")); - assert!(doc.resources.contains_key("users")); - assert!(doc.resources.contains_key("collaborations")); - - // Check users has 6 methods - let users = &doc.resources["users"]; - assert_eq!(users.methods.len(), 6); - assert!(users.methods.contains_key("get")); - assert!(users.methods.contains_key("list")); - assert!(users.methods.contains_key("create")); - assert!(users.methods.contains_key("getCurrent")); - - // Check a method's HTTP method and path - let get_user = &users.methods["get"]; - assert_eq!(get_user.http_method, "GET"); - assert_eq!(get_user.path, "/users/{user_id}"); - - // Check parameters - assert!(get_user.parameters.contains_key("user_id")); - let user_id_param = &get_user.parameters["user_id"]; - assert_eq!(user_id_param.location.as_deref(), Some("path")); - assert!(user_id_param.required); - } - - #[test] - fn test_load_bigcommerce_spec() { - // BigCommerce ships per-domain specs (vendored from docs-v2). Pick a - // representative one to verify the parser handles them — the CLI binary - // wires up all 36 of them via `spec_under` namespaces. - let yaml = include_str!("../../cli/bigcommerce/specs/management/customers.v3.yml"); - let doc = load_openapi_spec(yaml, "bigcommerce").unwrap(); - assert_eq!(doc.name, "bigcommerce"); - assert!(doc.root_url.contains("api.bigcommerce.com")); - } - // ------------------------------------------------------------------ // x-fern-idempotent + x-fern-idempotency-headers (FER-9864 P1). // ------------------------------------------------------------------ @@ -6597,54 +6534,6 @@ paths: // -- Verification: from_str vs from_value round-trip -------------------- - /// Compare `load_openapi_spec` (from_str → Value → from_value) against - /// each real spec to ensure the round-trip path doesn't lose or corrupt - /// any operations, resources, or schemas. - #[test] - fn test_roundtrip_bigcommerce_customers_v3() { - let yaml = include_str!("../../cli/bigcommerce/specs/management/customers.v3.yml"); - let doc = load_openapi_spec(yaml, "bigcommerce").unwrap(); - assert!(!doc.resources.is_empty(), "customers.v3 should have resources"); - assert!(!doc.schemas.is_empty(), "customers.v3 should have schemas"); - } - - #[test] - fn test_roundtrip_bigcommerce_orders_v2() { - let yaml = include_str!("../../cli/bigcommerce/specs/management/orders.v2.yml"); - let doc = load_openapi_spec(yaml, "bigcommerce").unwrap(); - assert!(!doc.resources.is_empty(), "orders.v2 should have resources"); - } - - #[test] - fn test_roundtrip_box_spec() { - let yaml = include_str!("../../cli/box/openapi.yaml"); - let doc = load_openapi_spec(yaml, "box").unwrap(); - assert!(!doc.resources.is_empty(), "box should have resources"); - assert!(!doc.schemas.is_empty(), "box should have schemas"); - } - - /// Verify the from_str → from_value path produces identical results to - /// what the old direct from_str path would have produced. We compare - /// resource keys and method counts to ensure no operations are lost. - #[test] - fn test_roundtrip_consistency_all_bigcommerce_specs() { - let specs: &[(&str, &str)] = &[ - ("customers.v3", include_str!("../../cli/bigcommerce/specs/management/customers.v3.yml")), - ("orders.v3", include_str!("../../cli/bigcommerce/specs/management/orders.v3.yml")), - ("orders.v2", include_str!("../../cli/bigcommerce/specs/management/orders.v2.yml")), - ("checkouts.v3", include_str!("../../cli/bigcommerce/specs/management/checkouts.v3.yml")), - ("channels.v3", include_str!("../../cli/bigcommerce/specs/management/channels.v3.yml")), - ("carts.v3", include_str!("../../cli/bigcommerce/specs/management/carts.v3.yml")), - ("shipping.v3", include_str!("../../cli/bigcommerce/specs/management/shipping.v3.yml")), - ]; - for (name, yaml) in specs { - let doc = load_openapi_spec(yaml, "bigcommerce"); - assert!(doc.is_ok(), "spec {name} should parse without error: {:?}", doc.err()); - let doc = doc.unwrap(); - assert!(!doc.resources.is_empty(), "spec {name} should have resources"); - } - } - // -- Verification: allowNullKeys covers all Fern CLI keys --------------- #[test] @@ -6697,37 +6586,6 @@ paths: // -- Verification: real overrides e2e ----------------------------------- - #[test] - fn test_real_overrides_e2e_bigcommerce_customers_v3() { - let base_yaml = include_str!("../../cli/bigcommerce/specs/management/customers.v3.yml"); - let overrides_yaml = r#" -paths: - /customers: - get: - x-fern-sdk-group-name: [customers] - x-fern-sdk-method-name: list - post: - x-fern-sdk-group-name: [customers] - x-fern-sdk-method-name: create - /customers/{customerId}: - put: - x-fern-sdk-group-name: [customers] - x-fern-sdk-method-name: update - delete: - x-fern-sdk-group-name: [customers] - x-fern-sdk-method-name: delete -"#; - let base: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); - let ovr: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); - let merged = deep_merge_yaml(base, ovr); - let doc = load_openapi_spec_from_value(merged, "bigcommerce").unwrap(); - let customers = &doc.resources["customers"]; - assert!(customers.methods.contains_key("list"), "override should create 'list' method"); - assert!(customers.methods.contains_key("create"), "override should create 'create' method"); - assert!(customers.methods.contains_key("update"), "override should create 'update' method"); - assert!(customers.methods.contains_key("delete"), "override should create 'delete' method"); - } - // -- Verification: all_objects heuristic -------------------------------- #[test] @@ -8645,24 +8503,6 @@ paths: // numeric exclusive bounds, const, composition, webhooks, schema-level // examples) against actual wire shapes rather than synthetic snippets. - #[test] - fn test_real_31_devin_spec_parses() { - let yaml = include_str!("../../cli/devin/openapi.yaml"); - load_openapi_spec(yaml, "devin").expect("devin 3.1 spec should parse"); - } - - #[test] - fn test_real_31_paid_spec_parses() { - let yaml = include_str!("../../cli/paid/specs/openapi.yml"); - load_openapi_spec(yaml, "paid").expect("paid 3.1 spec should parse"); - } - - #[test] - fn test_real_31_assemblyai_spec_parses() { - let yaml = include_str!("../../cli/assemblyai/specs/openapi.yml"); - load_openapi_spec(yaml, "assemblyai").expect("assemblyai 3.1 spec should parse"); - } - // -- JSON Schema composition (oneOf / anyOf / allOf) ----------------- #[test] From 36fbb49660c2ae4e6c466326c2d8bb0f4c0cc6bd Mon Sep 17 00:00:00 2001 From: jsklan <100491078+jsklan@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:51:18 -0400 Subject: [PATCH 06/14] chore(internal): Fix test snapshots for v3 parser (#16196) Fix test snapshots for v3 parser --- .../baseline-sdks/url-reference.json | 3011 ++++++++++++-- .../__snapshots__/v3-sdks/url-reference.json | 3697 ++++++++++++++++- 2 files changed, 6219 insertions(+), 489 deletions(-) diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/url-reference.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/url-reference.json index f4d26f1b94d4..d9591a802d3b 100644 --- a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/url-reference.json +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/url-reference.json @@ -1,6 +1,7 @@ { "selfHosted": false, "apiName": "api", + "apiDisplayName": "URL Reference API", "auth": { "requirement": "ALL", "schemes": [] @@ -210,6 +211,241 @@ "userProvidedExamples": [], "autogeneratedExamples": [] }, + "type_:Mammal": { + "name": { + "name": "Mammal", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Mammal" + }, + "shape": { + "members": [ + { + "type": { + "name": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "displayName": "Pet", + "typeId": "type_:Pet", + "type": "named" + } + }, + { + "type": { + "name": "User", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "displayName": "User", + "typeId": "type_:User", + "type": "named" + } + } + ], + "type": "undiscriminatedUnion" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:PetStatus": { + "docs": "pet status in the store", + "inline": true, + "name": { + "name": "PetStatus", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PetStatus" + }, + "shape": { + "values": [ + { + "name": "available" + }, + { + "name": "pending" + }, + { + "name": "sold" + } + ], + "type": "enum" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:Pet": { + "docs": "A pet for sale in the pet store", + "name": { + "name": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Pet" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "category", + "valueType": { + "container": { + "optional": { + "name": "Category", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Category", + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "name", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "photoUrls", + "valueType": { + "container": { + "list": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "list" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "tags", + "valueType": { + "container": { + "optional": { + "container": { + "list": { + "name": "Tag", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Tag", + "type": "named" + }, + "type": "list" + }, + "type": "container" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "docs": "pet status in the store", + "name": "status", + "valueType": { + "container": { + "optional": { + "name": "PetStatus", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:PetStatus", + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, "type_:PetOwner": { "name": { "name": "PetOwner", @@ -234,419 +470,2392 @@ }, "userProvidedExamples": [], "autogeneratedExamples": [] - } - }, - "errors": {}, - "services": {}, - "constants": { - "errorInstanceIdKey": "errorInstanceId" - }, - "errorDiscriminationStrategy": { - "type": "statusCode" - }, - "pathParameters": [], - "variables": [], - "serviceTypeReferenceInfo": { - "typesReferencedOnlyByService": {}, - "sharedTypes": [ - "type_petowners:PetownersSubscribeParamsChannel", - "type_petowners:PetownersSubscribeParamsData", - "type_petowners:PetownersSubscribeParams", - "type_petowners:PetownersSubscribe", - "type_:PetOwner" - ] - }, - "webhookGroups": {}, - "websocketChannels": { - "channel_petowners": { - "path": { - "head": "/petowners", - "parts": [] + }, + "type_:OrderStatus": { + "docs": "Order Status", + "inline": true, + "name": { + "name": "OrderStatus", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:OrderStatus" }, - "auth": false, - "name": "petowners", - "headers": [], - "docs": "Private websocket channel for receiving updates about pet owners and their pets", - "pathParameters": [], - "queryParameters": [], - "messages": [ - { - "type": "subscribe", - "origin": "server", - "body": { - "bodyType": { - "name": "PetownersSubscribe", - "fernFilepath": { - "allParts": [ - "petowners" - ], - "packagePath": [], - "file": "petowners" + "shape": { + "values": [ + { + "name": "placed" + }, + { + "name": "approved" + }, + { + "name": "delivered" + } + ], + "type": "enum" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:Order": { + "docs": "An order for a pets from the pet store", + "name": { + "name": "Order", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Order" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" }, - "typeId": "type_petowners:PetownersSubscribe", - "type": "named" + "type": "container" }, - "type": "reference" - } - } - ], - "examples": [ - { - "url": "/petowners", - "pathParameters": [], - "headers": [], - "queryParameters": [], - "messages": [ - { - "type": "subscribe", - "body": { - "shape": { - "typeName": { - "typeId": "type_petowners:PetownersSubscribe", - "fernFilepath": { - "allParts": [ - "petowners" - ], - "packagePath": [], - "file": "petowners" - }, - "name": "PetownersSubscribe" + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "petId", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "type": "long" + } }, - "shape": { - "properties": [], - "type": "object" + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "quantity", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "shipDate", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "DATE_TIME" }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "docs": "Order Status", + "name": "status", + "valueType": { + "container": { + "optional": { + "name": "OrderStatus", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:OrderStatus", "type": "named" }, - "jsonExample": {}, - "type": "reference" + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "complete", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "default": false, + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "defaultValue": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:Category": { + "docs": "A category for a pet", + "name": { + "name": "Category", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Category" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:Tag": { + "docs": "A tag for a pet", + "name": { + "name": "Tag", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Tag" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + }, + "type_:User": { + "docs": "A User who is purchasing from the pet store", + "name": { + "name": "User", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:User" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "username", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "firstName", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "lastName", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "email", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "password", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "phone", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "docs": "User Status", + "name": "userStatus", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + } + }, + "errors": {}, + "services": { + "service_pet": { + "name": { + "fernFilepath": { + "allParts": [ + "pet" + ], + "packagePath": [], + "file": "pet" + } + }, + "basePath": { + "head": "", + "parts": [] + }, + "headers": [], + "pathParameters": [], + "encoding": { + "json": {} + }, + "transport": { + "type": "http" + }, + "endpoints": [ + { + "id": "endpoint_pet.updateAnExistingPet", + "name": "updateAnExistingPet", + "displayName": "Update an existing pet", + "auth": false, + "idempotent": false, + "method": "PUT", + "path": { + "head": "/pet", + "parts": [] + }, + "fullPath": { + "head": "pet", + "parts": [] + }, + "pathParameters": [], + "allPathParameters": [], + "queryParameters": [], + "headers": [], + "requestBody": { + "requestBodyType": { + "name": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Pet", + "type": "named" + }, + "contentType": "application/json", + "type": "reference" + }, + "sdkRequest": { + "shape": { + "value": { + "requestBodyType": { + "name": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Pet", + "type": "named" + }, + "type": "typeReference" + }, + "type": "justRequestBody" + }, + "requestParameterName": "request" + }, + "response": { + "body": { + "value": { + "docs": "A list of pets", + "responseBodyType": { + "name": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Pet", + "type": "named" + }, + "type": "response" + }, + "type": "json" + }, + "statusCode": 200, + "docs": "A list of pets" + }, + "errors": [], + "userSpecifiedExamples": [], + "autogeneratedExamples": [], + "responseHeaders": [] + } + ] + }, + "service_order": { + "name": { + "fernFilepath": { + "allParts": [ + "order" + ], + "packagePath": [], + "file": "order" + } + }, + "basePath": { + "head": "", + "parts": [] + }, + "headers": [], + "pathParameters": [], + "encoding": { + "json": {} + }, + "transport": { + "type": "http" + }, + "endpoints": [ + { + "id": "endpoint_order.getAnOrder", + "name": "getAnOrder", + "displayName": "Get an order", + "auth": false, + "idempotent": false, + "method": "GET", + "path": { + "head": "/order", + "parts": [] + }, + "fullPath": { + "head": "order", + "parts": [] + }, + "pathParameters": [], + "allPathParameters": [], + "queryParameters": [], + "headers": [], + "response": { + "body": { + "value": { + "docs": "An order object", + "responseBodyType": { + "name": "Order", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Order", + "type": "named" + }, + "type": "response" + }, + "type": "json" + }, + "statusCode": 200, + "docs": "An order object" + }, + "errors": [], + "userSpecifiedExamples": [], + "autogeneratedExamples": [], + "responseHeaders": [] + } + ] + } + }, + "constants": { + "errorInstanceIdKey": "errorInstanceId" + }, + "errorDiscriminationStrategy": { + "type": "statusCode" + }, + "pathParameters": [], + "variables": [], + "serviceTypeReferenceInfo": { + "typesReferencedOnlyByService": { + "service_pet": [ + "type_:PetStatus", + "type_:Pet", + "type_:Category", + "type_:Tag" + ], + "service_order": [ + "type_:OrderStatus", + "type_:Order" + ] + }, + "sharedTypes": [ + "type_petowners:PetownersSubscribeParamsChannel", + "type_petowners:PetownersSubscribeParamsData", + "type_petowners:PetownersSubscribeParams", + "type_petowners:PetownersSubscribe", + "type_:Mammal", + "type_:PetOwner", + "type_:User" + ] + }, + "webhookGroups": {}, + "websocketChannels": { + "channel_petowners": { + "path": { + "head": "/petowners", + "parts": [] + }, + "auth": false, + "name": "petowners", + "headers": [], + "docs": "Private websocket channel for receiving updates about pet owners and their pets", + "pathParameters": [], + "queryParameters": [], + "messages": [ + { + "type": "subscribe", + "origin": "server", + "body": { + "bodyType": { + "name": "PetownersSubscribe", + "fernFilepath": { + "allParts": [ + "petowners" + ], + "packagePath": [], + "file": "petowners" + }, + "typeId": "type_petowners:PetownersSubscribe", + "type": "named" + }, + "type": "reference" + } + } + ], + "examples": [ + { + "url": "/petowners", + "pathParameters": [], + "headers": [], + "queryParameters": [], + "messages": [ + { + "type": "subscribe", + "body": { + "shape": { + "typeName": { + "typeId": "type_petowners:PetownersSubscribe", + "fernFilepath": { + "allParts": [ + "petowners" + ], + "packagePath": [], + "file": "petowners" + }, + "name": "PetownersSubscribe" + }, + "shape": { + "properties": [], + "type": "object" + }, + "type": "named" + }, + "jsonExample": {}, + "type": "reference" + } + } + ] + } + ], + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + }, + "dynamic": { + "version": "1.0.0", + "types": { + "type_petowners:PetownersSubscribeParamsChannel": { + "declaration": { + "name": { + "originalName": "PetownersSubscribeParamsChannel", + "camelCase": { + "unsafeName": "petownersSubscribeParamsChannel", + "safeName": "petownersSubscribeParamsChannel" + }, + "snakeCase": { + "unsafeName": "petowners_subscribe_params_channel", + "safeName": "petowners_subscribe_params_channel" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS_CHANNEL", + "safeName": "PETOWNERS_SUBSCRIBE_PARAMS_CHANNEL" + }, + "pascalCase": { + "unsafeName": "PetownersSubscribeParamsChannel", + "safeName": "PetownersSubscribeParamsChannel" + } + }, + "fernFilepath": { + "allParts": [ + { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } + } + ], + "packagePath": [], + "file": { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } + } + } + }, + "values": [ + { + "wireValue": "petowners", + "name": { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } + } + } + ], + "type": "enum" + }, + "type_petowners:PetownersSubscribeParamsData": { + "declaration": { + "name": { + "originalName": "PetownersSubscribeParamsData", + "camelCase": { + "unsafeName": "petownersSubscribeParamsData", + "safeName": "petownersSubscribeParamsData" + }, + "snakeCase": { + "unsafeName": "petowners_subscribe_params_data", + "safeName": "petowners_subscribe_params_data" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS_DATA", + "safeName": "PETOWNERS_SUBSCRIBE_PARAMS_DATA" + }, + "pascalCase": { + "unsafeName": "PetownersSubscribeParamsData", + "safeName": "PetownersSubscribeParamsData" + } + }, + "fernFilepath": { + "allParts": [ + { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } + } + ], + "packagePath": [], + "file": { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } + } + } + }, + "properties": [ + { + "name": { + "wireValue": "petOwner", + "name": { + "originalName": "petOwner", + "camelCase": { + "unsafeName": "petOwner", + "safeName": "petOwner" + }, + "snakeCase": { + "unsafeName": "pet_owner", + "safeName": "pet_owner" + }, + "screamingSnakeCase": { + "unsafeName": "PET_OWNER", + "safeName": "PET_OWNER" + }, + "pascalCase": { + "unsafeName": "PetOwner", + "safeName": "PetOwner" + } + } + }, + "typeReference": { + "value": "type_:PetOwner", + "type": "named" + } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_petowners:PetownersSubscribeParams": { + "declaration": { + "name": { + "originalName": "PetownersSubscribeParams", + "camelCase": { + "unsafeName": "petownersSubscribeParams", + "safeName": "petownersSubscribeParams" + }, + "snakeCase": { + "unsafeName": "petowners_subscribe_params", + "safeName": "petowners_subscribe_params" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS", + "safeName": "PETOWNERS_SUBSCRIBE_PARAMS" + }, + "pascalCase": { + "unsafeName": "PetownersSubscribeParams", + "safeName": "PetownersSubscribeParams" + } + }, + "fernFilepath": { + "allParts": [ + { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } + } + ], + "packagePath": [], + "file": { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } + } + } + }, + "properties": [ + { + "name": { + "wireValue": "channel", + "name": { + "originalName": "channel", + "camelCase": { + "unsafeName": "channel", + "safeName": "channel" + }, + "snakeCase": { + "unsafeName": "channel", + "safeName": "channel" + }, + "screamingSnakeCase": { + "unsafeName": "CHANNEL", + "safeName": "CHANNEL" + }, + "pascalCase": { + "unsafeName": "Channel", + "safeName": "Channel" + } + } + }, + "typeReference": { + "value": { + "value": "type_petowners:PetownersSubscribeParamsChannel", + "type": "named" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "data", + "name": { + "originalName": "data", + "camelCase": { + "unsafeName": "data", + "safeName": "data" + }, + "snakeCase": { + "unsafeName": "data", + "safeName": "data" + }, + "screamingSnakeCase": { + "unsafeName": "DATA", + "safeName": "DATA" + }, + "pascalCase": { + "unsafeName": "Data", + "safeName": "Data" + } + } + }, + "typeReference": { + "value": { + "value": "type_petowners:PetownersSubscribeParamsData", + "type": "named" + }, + "type": "optional" + } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_petowners:PetownersSubscribe": { + "declaration": { + "name": { + "originalName": "PetownersSubscribe", + "camelCase": { + "unsafeName": "petownersSubscribe", + "safeName": "petownersSubscribe" + }, + "snakeCase": { + "unsafeName": "petowners_subscribe", + "safeName": "petowners_subscribe" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS_SUBSCRIBE", + "safeName": "PETOWNERS_SUBSCRIBE" + }, + "pascalCase": { + "unsafeName": "PetownersSubscribe", + "safeName": "PetownersSubscribe" + } + }, + "fernFilepath": { + "allParts": [ + { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } + } + ], + "packagePath": [], + "file": { + "originalName": "petowners", + "camelCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "snakeCase": { + "unsafeName": "petowners", + "safeName": "petowners" + }, + "screamingSnakeCase": { + "unsafeName": "PETOWNERS", + "safeName": "PETOWNERS" + }, + "pascalCase": { + "unsafeName": "Petowners", + "safeName": "Petowners" + } + } + } + }, + "properties": [ + { + "name": { + "wireValue": "params", + "name": { + "originalName": "params", + "camelCase": { + "unsafeName": "params", + "safeName": "params" + }, + "snakeCase": { + "unsafeName": "params", + "safeName": "params" + }, + "screamingSnakeCase": { + "unsafeName": "PARAMS", + "safeName": "PARAMS" + }, + "pascalCase": { + "unsafeName": "Params", + "safeName": "Params" + } + } + }, + "typeReference": { + "value": { + "value": "type_petowners:PetownersSubscribeParams", + "type": "named" + }, + "type": "optional" + } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_:Mammal": { + "declaration": { + "name": { + "originalName": "Mammal", + "camelCase": { + "unsafeName": "mammal", + "safeName": "mammal" + }, + "snakeCase": { + "unsafeName": "mammal", + "safeName": "mammal" + }, + "screamingSnakeCase": { + "unsafeName": "MAMMAL", + "safeName": "MAMMAL" + }, + "pascalCase": { + "unsafeName": "Mammal", + "safeName": "Mammal" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "types": [ + { + "value": "type_:Pet", + "type": "named" + }, + { + "value": "type_:User", + "type": "named" + } + ], + "type": "undiscriminatedUnion" + }, + "type_:PetStatus": { + "declaration": { + "name": { + "originalName": "PetStatus", + "camelCase": { + "unsafeName": "petStatus", + "safeName": "petStatus" + }, + "snakeCase": { + "unsafeName": "pet_status", + "safeName": "pet_status" + }, + "screamingSnakeCase": { + "unsafeName": "PET_STATUS", + "safeName": "PET_STATUS" + }, + "pascalCase": { + "unsafeName": "PetStatus", + "safeName": "PetStatus" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "values": [ + { + "wireValue": "available", + "name": { + "originalName": "available", + "camelCase": { + "unsafeName": "available", + "safeName": "available" + }, + "snakeCase": { + "unsafeName": "available", + "safeName": "available" + }, + "screamingSnakeCase": { + "unsafeName": "AVAILABLE", + "safeName": "AVAILABLE" + }, + "pascalCase": { + "unsafeName": "Available", + "safeName": "Available" + } + } + }, + { + "wireValue": "pending", + "name": { + "originalName": "pending", + "camelCase": { + "unsafeName": "pending", + "safeName": "pending" + }, + "snakeCase": { + "unsafeName": "pending", + "safeName": "pending" + }, + "screamingSnakeCase": { + "unsafeName": "PENDING", + "safeName": "PENDING" + }, + "pascalCase": { + "unsafeName": "Pending", + "safeName": "Pending" + } + } + }, + { + "wireValue": "sold", + "name": { + "originalName": "sold", + "camelCase": { + "unsafeName": "sold", + "safeName": "sold" + }, + "snakeCase": { + "unsafeName": "sold", + "safeName": "sold" + }, + "screamingSnakeCase": { + "unsafeName": "SOLD", + "safeName": "SOLD" + }, + "pascalCase": { + "unsafeName": "Sold", + "safeName": "Sold" + } + } + } + ], + "type": "enum" + }, + "type_:Pet": { + "declaration": { + "name": { + "originalName": "Pet", + "camelCase": { + "unsafeName": "pet", + "safeName": "pet" + }, + "snakeCase": { + "unsafeName": "pet", + "safeName": "pet" + }, + "screamingSnakeCase": { + "unsafeName": "PET", + "safeName": "PET" + }, + "pascalCase": { + "unsafeName": "Pet", + "safeName": "Pet" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "id", + "name": { + "originalName": "id", + "camelCase": { + "unsafeName": "id", + "safeName": "id" + }, + "snakeCase": { + "unsafeName": "id", + "safeName": "id" + }, + "screamingSnakeCase": { + "unsafeName": "ID", + "safeName": "ID" + }, + "pascalCase": { + "unsafeName": "ID", + "safeName": "ID" + } + } + }, + "typeReference": { + "value": { + "value": "LONG", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "category", + "name": { + "originalName": "category", + "camelCase": { + "unsafeName": "category", + "safeName": "category" + }, + "snakeCase": { + "unsafeName": "category", + "safeName": "category" + }, + "screamingSnakeCase": { + "unsafeName": "CATEGORY", + "safeName": "CATEGORY" + }, + "pascalCase": { + "unsafeName": "Category", + "safeName": "Category" + } + } + }, + "typeReference": { + "value": { + "value": "type_:Category", + "type": "named" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "name", + "name": { + "originalName": "name", + "camelCase": { + "unsafeName": "name", + "safeName": "name" + }, + "snakeCase": { + "unsafeName": "name", + "safeName": "name" + }, + "screamingSnakeCase": { + "unsafeName": "NAME", + "safeName": "NAME" + }, + "pascalCase": { + "unsafeName": "Name", + "safeName": "Name" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "photoUrls", + "name": { + "originalName": "photoUrls", + "camelCase": { + "unsafeName": "photoURLs", + "safeName": "photoURLs" + }, + "snakeCase": { + "unsafeName": "photo_urls", + "safeName": "photo_urls" + }, + "screamingSnakeCase": { + "unsafeName": "PHOTO_URLS", + "safeName": "PHOTO_URLS" + }, + "pascalCase": { + "unsafeName": "PhotoURLs", + "safeName": "PhotoURLs" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "list" + } + }, + { + "name": { + "wireValue": "tags", + "name": { + "originalName": "tags", + "camelCase": { + "unsafeName": "tags", + "safeName": "tags" + }, + "snakeCase": { + "unsafeName": "tags", + "safeName": "tags" + }, + "screamingSnakeCase": { + "unsafeName": "TAGS", + "safeName": "TAGS" + }, + "pascalCase": { + "unsafeName": "Tags", + "safeName": "Tags" + } + } + }, + "typeReference": { + "value": { + "value": { + "value": "type_:Tag", + "type": "named" + }, + "type": "list" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "status", + "name": { + "originalName": "status", + "camelCase": { + "unsafeName": "status", + "safeName": "status" + }, + "snakeCase": { + "unsafeName": "status", + "safeName": "status" + }, + "screamingSnakeCase": { + "unsafeName": "STATUS", + "safeName": "STATUS" + }, + "pascalCase": { + "unsafeName": "Status", + "safeName": "Status" + } + } + }, + "typeReference": { + "value": { + "value": "type_:PetStatus", + "type": "named" + }, + "type": "optional" + } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_:PetOwner": { + "declaration": { + "name": { + "originalName": "PetOwner", + "camelCase": { + "unsafeName": "petOwner", + "safeName": "petOwner" + }, + "snakeCase": { + "unsafeName": "pet_owner", + "safeName": "pet_owner" + }, + "screamingSnakeCase": { + "unsafeName": "PET_OWNER", + "safeName": "PET_OWNER" + }, + "pascalCase": { + "unsafeName": "PetOwner", + "safeName": "PetOwner" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "typeReference": { + "type": "unknown" + }, + "type": "alias" + }, + "type_:OrderStatus": { + "declaration": { + "name": { + "originalName": "OrderStatus", + "camelCase": { + "unsafeName": "orderStatus", + "safeName": "orderStatus" + }, + "snakeCase": { + "unsafeName": "order_status", + "safeName": "order_status" + }, + "screamingSnakeCase": { + "unsafeName": "ORDER_STATUS", + "safeName": "ORDER_STATUS" + }, + "pascalCase": { + "unsafeName": "OrderStatus", + "safeName": "OrderStatus" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "values": [ + { + "wireValue": "placed", + "name": { + "originalName": "placed", + "camelCase": { + "unsafeName": "placed", + "safeName": "placed" + }, + "snakeCase": { + "unsafeName": "placed", + "safeName": "placed" + }, + "screamingSnakeCase": { + "unsafeName": "PLACED", + "safeName": "PLACED" + }, + "pascalCase": { + "unsafeName": "Placed", + "safeName": "Placed" + } + } + }, + { + "wireValue": "approved", + "name": { + "originalName": "approved", + "camelCase": { + "unsafeName": "approved", + "safeName": "approved" + }, + "snakeCase": { + "unsafeName": "approved", + "safeName": "approved" + }, + "screamingSnakeCase": { + "unsafeName": "APPROVED", + "safeName": "APPROVED" + }, + "pascalCase": { + "unsafeName": "Approved", + "safeName": "Approved" + } + } + }, + { + "wireValue": "delivered", + "name": { + "originalName": "delivered", + "camelCase": { + "unsafeName": "delivered", + "safeName": "delivered" + }, + "snakeCase": { + "unsafeName": "delivered", + "safeName": "delivered" + }, + "screamingSnakeCase": { + "unsafeName": "DELIVERED", + "safeName": "DELIVERED" + }, + "pascalCase": { + "unsafeName": "Delivered", + "safeName": "Delivered" + } + } + } + ], + "type": "enum" + }, + "type_:Order": { + "declaration": { + "name": { + "originalName": "Order", + "camelCase": { + "unsafeName": "order", + "safeName": "order" + }, + "snakeCase": { + "unsafeName": "order", + "safeName": "order" + }, + "screamingSnakeCase": { + "unsafeName": "ORDER", + "safeName": "ORDER" + }, + "pascalCase": { + "unsafeName": "Order", + "safeName": "Order" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "id", + "name": { + "originalName": "id", + "camelCase": { + "unsafeName": "id", + "safeName": "id" + }, + "snakeCase": { + "unsafeName": "id", + "safeName": "id" + }, + "screamingSnakeCase": { + "unsafeName": "ID", + "safeName": "ID" + }, + "pascalCase": { + "unsafeName": "ID", + "safeName": "ID" + } + } + }, + "typeReference": { + "value": { + "value": "LONG", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "petId", + "name": { + "originalName": "petId", + "camelCase": { + "unsafeName": "petID", + "safeName": "petID" + }, + "snakeCase": { + "unsafeName": "pet_id", + "safeName": "pet_id" + }, + "screamingSnakeCase": { + "unsafeName": "PET_ID", + "safeName": "PET_ID" + }, + "pascalCase": { + "unsafeName": "PetID", + "safeName": "PetID" + } + } + }, + "typeReference": { + "value": { + "value": "LONG", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "quantity", + "name": { + "originalName": "quantity", + "camelCase": { + "unsafeName": "quantity", + "safeName": "quantity" + }, + "snakeCase": { + "unsafeName": "quantity", + "safeName": "quantity" + }, + "screamingSnakeCase": { + "unsafeName": "QUANTITY", + "safeName": "QUANTITY" + }, + "pascalCase": { + "unsafeName": "Quantity", + "safeName": "Quantity" + } + } + }, + "typeReference": { + "value": { + "value": "INTEGER", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "shipDate", + "name": { + "originalName": "shipDate", + "camelCase": { + "unsafeName": "shipDate", + "safeName": "shipDate" + }, + "snakeCase": { + "unsafeName": "ship_date", + "safeName": "ship_date" + }, + "screamingSnakeCase": { + "unsafeName": "SHIP_DATE", + "safeName": "SHIP_DATE" + }, + "pascalCase": { + "unsafeName": "ShipDate", + "safeName": "ShipDate" + } + } + }, + "typeReference": { + "value": { + "value": "DATE_TIME", + "type": "primitive" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "status", + "name": { + "originalName": "status", + "camelCase": { + "unsafeName": "status", + "safeName": "status" + }, + "snakeCase": { + "unsafeName": "status", + "safeName": "status" + }, + "screamingSnakeCase": { + "unsafeName": "STATUS", + "safeName": "STATUS" + }, + "pascalCase": { + "unsafeName": "Status", + "safeName": "Status" + } + } + }, + "typeReference": { + "value": { + "value": "type_:OrderStatus", + "type": "named" + }, + "type": "optional" + } + }, + { + "name": { + "wireValue": "complete", + "name": { + "originalName": "complete", + "camelCase": { + "unsafeName": "complete", + "safeName": "complete" + }, + "snakeCase": { + "unsafeName": "complete", + "safeName": "complete" + }, + "screamingSnakeCase": { + "unsafeName": "COMPLETE", + "safeName": "COMPLETE" + }, + "pascalCase": { + "unsafeName": "Complete", + "safeName": "Complete" + } } + }, + "typeReference": { + "value": { + "value": "BOOLEAN", + "type": "primitive" + }, + "type": "optional" } - ] - } - ], - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": {} - } - } - }, - "dynamic": { - "version": "1.0.0", - "types": { - "type_petowners:PetownersSubscribeParamsChannel": { + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_:Category": { "declaration": { "name": { - "originalName": "PetownersSubscribeParamsChannel", + "originalName": "Category", "camelCase": { - "unsafeName": "petownersSubscribeParamsChannel", - "safeName": "petownersSubscribeParamsChannel" + "unsafeName": "category", + "safeName": "category" }, "snakeCase": { - "unsafeName": "petowners_subscribe_params_channel", - "safeName": "petowners_subscribe_params_channel" + "unsafeName": "category", + "safeName": "category" }, "screamingSnakeCase": { - "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS_CHANNEL", - "safeName": "PETOWNERS_SUBSCRIBE_PARAMS_CHANNEL" + "unsafeName": "CATEGORY", + "safeName": "CATEGORY" }, "pascalCase": { - "unsafeName": "PetownersSubscribeParamsChannel", - "safeName": "PetownersSubscribeParamsChannel" + "unsafeName": "Category", + "safeName": "Category" } }, "fernFilepath": { - "allParts": [ - { - "originalName": "petowners", + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "id", + "name": { + "originalName": "id", "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" + "unsafeName": "id", + "safeName": "id" }, "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" + "unsafeName": "id", + "safeName": "id" }, "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" + "unsafeName": "ID", + "safeName": "ID" }, "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" + "unsafeName": "ID", + "safeName": "ID" } } - ], - "packagePath": [], - "file": { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" + }, + "typeReference": { + "value": { + "value": "LONG", + "type": "primitive" }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" + "type": "optional" + } + }, + { + "name": { + "wireValue": "name", + "name": { + "originalName": "name", + "camelCase": { + "unsafeName": "name", + "safeName": "name" + }, + "snakeCase": { + "unsafeName": "name", + "safeName": "name" + }, + "screamingSnakeCase": { + "unsafeName": "NAME", + "safeName": "NAME" + }, + "pascalCase": { + "unsafeName": "Name", + "safeName": "Name" + } } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "additionalProperties": false, + "type": "object" + }, + "type_:Tag": { + "declaration": { + "name": { + "originalName": "Tag", + "camelCase": { + "unsafeName": "tag", + "safeName": "tag" + }, + "snakeCase": { + "unsafeName": "tag", + "safeName": "tag" + }, + "screamingSnakeCase": { + "unsafeName": "TAG", + "safeName": "TAG" + }, + "pascalCase": { + "unsafeName": "Tag", + "safeName": "Tag" } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] } }, - "values": [ + "properties": [ { - "wireValue": "petowners", "name": { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" + "wireValue": "id", + "name": { + "originalName": "id", + "camelCase": { + "unsafeName": "id", + "safeName": "id" + }, + "snakeCase": { + "unsafeName": "id", + "safeName": "id" + }, + "screamingSnakeCase": { + "unsafeName": "ID", + "safeName": "ID" + }, + "pascalCase": { + "unsafeName": "ID", + "safeName": "ID" + } + } + }, + "typeReference": { + "value": { + "value": "LONG", + "type": "primitive" }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" + "type": "optional" + } + }, + { + "name": { + "wireValue": "name", + "name": { + "originalName": "name", + "camelCase": { + "unsafeName": "name", + "safeName": "name" + }, + "snakeCase": { + "unsafeName": "name", + "safeName": "name" + }, + "screamingSnakeCase": { + "unsafeName": "NAME", + "safeName": "NAME" + }, + "pascalCase": { + "unsafeName": "Name", + "safeName": "Name" + } } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" } } ], - "type": "enum" + "additionalProperties": false, + "type": "object" }, - "type_petowners:PetownersSubscribeParamsData": { + "type_:User": { "declaration": { "name": { - "originalName": "PetownersSubscribeParamsData", + "originalName": "User", "camelCase": { - "unsafeName": "petownersSubscribeParamsData", - "safeName": "petownersSubscribeParamsData" + "unsafeName": "user", + "safeName": "user" }, "snakeCase": { - "unsafeName": "petowners_subscribe_params_data", - "safeName": "petowners_subscribe_params_data" + "unsafeName": "user", + "safeName": "user" }, "screamingSnakeCase": { - "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS_DATA", - "safeName": "PETOWNERS_SUBSCRIBE_PARAMS_DATA" + "unsafeName": "USER", + "safeName": "USER" }, "pascalCase": { - "unsafeName": "PetownersSubscribeParamsData", - "safeName": "PetownersSubscribeParamsData" + "unsafeName": "User", + "safeName": "User" } }, "fernFilepath": { - "allParts": [ - { - "originalName": "petowners", + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "id", + "name": { + "originalName": "id", "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" + "unsafeName": "id", + "safeName": "id" }, "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" + "unsafeName": "id", + "safeName": "id" }, "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" + "unsafeName": "ID", + "safeName": "ID" }, "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" + "unsafeName": "ID", + "safeName": "ID" } } - ], - "packagePath": [], - "file": { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" + }, + "typeReference": { + "value": { + "value": "LONG", + "type": "primitive" }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" + "type": "optional" + } + }, + { + "name": { + "wireValue": "username", + "name": { + "originalName": "username", + "camelCase": { + "unsafeName": "username", + "safeName": "username" + }, + "snakeCase": { + "unsafeName": "username", + "safeName": "username" + }, + "screamingSnakeCase": { + "unsafeName": "USERNAME", + "safeName": "USERNAME" + }, + "pascalCase": { + "unsafeName": "Username", + "safeName": "Username" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" + "type": "optional" + } + }, + { + "name": { + "wireValue": "firstName", + "name": { + "originalName": "firstName", + "camelCase": { + "unsafeName": "firstName", + "safeName": "firstName" + }, + "snakeCase": { + "unsafeName": "first_name", + "safeName": "first_name" + }, + "screamingSnakeCase": { + "unsafeName": "FIRST_NAME", + "safeName": "FIRST_NAME" + }, + "pascalCase": { + "unsafeName": "FirstName", + "safeName": "FirstName" + } } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" } - } - }, - "properties": [ + }, { "name": { - "wireValue": "petOwner", + "wireValue": "lastName", "name": { - "originalName": "petOwner", + "originalName": "lastName", "camelCase": { - "unsafeName": "petOwner", - "safeName": "petOwner" + "unsafeName": "lastName", + "safeName": "lastName" }, "snakeCase": { - "unsafeName": "pet_owner", - "safeName": "pet_owner" + "unsafeName": "last_name", + "safeName": "last_name" }, "screamingSnakeCase": { - "unsafeName": "PET_OWNER", - "safeName": "PET_OWNER" + "unsafeName": "LAST_NAME", + "safeName": "LAST_NAME" }, "pascalCase": { - "unsafeName": "PetOwner", - "safeName": "PetOwner" + "unsafeName": "LastName", + "safeName": "LastName" } } }, "typeReference": { - "value": "type_:PetOwner", - "type": "named" + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" } - } - ], - "additionalProperties": false, - "type": "object" - }, - "type_petowners:PetownersSubscribeParams": { - "declaration": { - "name": { - "originalName": "PetownersSubscribeParams", - "camelCase": { - "unsafeName": "petownersSubscribeParams", - "safeName": "petownersSubscribeParams" - }, - "snakeCase": { - "unsafeName": "petowners_subscribe_params", - "safeName": "petowners_subscribe_params" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS_SUBSCRIBE_PARAMS", - "safeName": "PETOWNERS_SUBSCRIBE_PARAMS" + }, + { + "name": { + "wireValue": "email", + "name": { + "originalName": "email", + "camelCase": { + "unsafeName": "email", + "safeName": "email" + }, + "snakeCase": { + "unsafeName": "email", + "safeName": "email" + }, + "screamingSnakeCase": { + "unsafeName": "EMAIL", + "safeName": "EMAIL" + }, + "pascalCase": { + "unsafeName": "Email", + "safeName": "Email" + } + } }, - "pascalCase": { - "unsafeName": "PetownersSubscribeParams", - "safeName": "PetownersSubscribeParams" + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" } }, - "fernFilepath": { - "allParts": [ - { - "originalName": "petowners", + { + "name": { + "wireValue": "password", + "name": { + "originalName": "password", "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" + "unsafeName": "password", + "safeName": "password" }, "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" + "unsafeName": "password", + "safeName": "password" }, "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" + "unsafeName": "PASSWORD", + "safeName": "PASSWORD" }, "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" + "unsafeName": "Password", + "safeName": "Password" } } - ], - "packagePath": [], - "file": { - "originalName": "petowners", - "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" - }, - "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" }, - "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" - } + "type": "optional" } - } - }, - "properties": [ + }, { "name": { - "wireValue": "channel", + "wireValue": "phone", "name": { - "originalName": "channel", + "originalName": "phone", "camelCase": { - "unsafeName": "channel", - "safeName": "channel" + "unsafeName": "phone", + "safeName": "phone" }, "snakeCase": { - "unsafeName": "channel", - "safeName": "channel" + "unsafeName": "phone", + "safeName": "phone" }, "screamingSnakeCase": { - "unsafeName": "CHANNEL", - "safeName": "CHANNEL" + "unsafeName": "PHONE", + "safeName": "PHONE" }, "pascalCase": { - "unsafeName": "Channel", - "safeName": "Channel" + "unsafeName": "Phone", + "safeName": "Phone" } } }, "typeReference": { "value": { - "value": "type_petowners:PetownersSubscribeParamsChannel", - "type": "named" + "value": "STRING", + "type": "primitive" }, "type": "optional" } }, { "name": { - "wireValue": "data", + "wireValue": "userStatus", "name": { - "originalName": "data", + "originalName": "userStatus", "camelCase": { - "unsafeName": "data", - "safeName": "data" + "unsafeName": "userStatus", + "safeName": "userStatus" }, "snakeCase": { - "unsafeName": "data", - "safeName": "data" + "unsafeName": "user_status", + "safeName": "user_status" }, "screamingSnakeCase": { - "unsafeName": "DATA", - "safeName": "DATA" + "unsafeName": "USER_STATUS", + "safeName": "USER_STATUS" }, "pascalCase": { - "unsafeName": "Data", - "safeName": "Data" + "unsafeName": "UserStatus", + "safeName": "UserStatus" } } }, "typeReference": { "value": { - "value": "type_petowners:PetownersSubscribeParamsData", - "type": "named" + "value": "INTEGER", + "type": "primitive" }, "type": "optional" } @@ -654,142 +2863,174 @@ ], "additionalProperties": false, "type": "object" - }, - "type_petowners:PetownersSubscribe": { + } + }, + "headers": [], + "endpoints": { + "endpoint_pet.updateAnExistingPet": { "declaration": { "name": { - "originalName": "PetownersSubscribe", + "originalName": "updateAnExistingPet", "camelCase": { - "unsafeName": "petownersSubscribe", - "safeName": "petownersSubscribe" + "unsafeName": "updateAnExistingPet", + "safeName": "updateAnExistingPet" }, "snakeCase": { - "unsafeName": "petowners_subscribe", - "safeName": "petowners_subscribe" + "unsafeName": "update_an_existing_pet", + "safeName": "update_an_existing_pet" }, "screamingSnakeCase": { - "unsafeName": "PETOWNERS_SUBSCRIBE", - "safeName": "PETOWNERS_SUBSCRIBE" + "unsafeName": "UPDATE_AN_EXISTING_PET", + "safeName": "UPDATE_AN_EXISTING_PET" }, "pascalCase": { - "unsafeName": "PetownersSubscribe", - "safeName": "PetownersSubscribe" + "unsafeName": "UpdateAnExistingPet", + "safeName": "UpdateAnExistingPet" } }, "fernFilepath": { "allParts": [ { - "originalName": "petowners", + "originalName": "pet", "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" + "unsafeName": "pet", + "safeName": "pet" }, "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" + "unsafeName": "pet", + "safeName": "pet" }, "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" + "unsafeName": "PET", + "safeName": "PET" }, "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" + "unsafeName": "Pet", + "safeName": "Pet" } } ], "packagePath": [], "file": { - "originalName": "petowners", + "originalName": "pet", "camelCase": { - "unsafeName": "petowners", - "safeName": "petowners" + "unsafeName": "pet", + "safeName": "pet" }, "snakeCase": { - "unsafeName": "petowners", - "safeName": "petowners" + "unsafeName": "pet", + "safeName": "pet" }, "screamingSnakeCase": { - "unsafeName": "PETOWNERS", - "safeName": "PETOWNERS" + "unsafeName": "PET", + "safeName": "PET" }, "pascalCase": { - "unsafeName": "Petowners", - "safeName": "Petowners" + "unsafeName": "Pet", + "safeName": "Pet" } } } }, - "properties": [ - { - "name": { - "wireValue": "params", - "name": { - "originalName": "params", - "camelCase": { - "unsafeName": "params", - "safeName": "params" - }, - "snakeCase": { - "unsafeName": "params", - "safeName": "params" - }, - "screamingSnakeCase": { - "unsafeName": "PARAMS", - "safeName": "PARAMS" - }, - "pascalCase": { - "unsafeName": "Params", - "safeName": "Params" - } - } + "location": { + "method": "PUT", + "path": "/pet" + }, + "request": { + "pathParameters": [], + "body": { + "value": { + "value": "type_:Pet", + "type": "named" }, - "typeReference": { - "value": { - "value": "type_petowners:PetownersSubscribeParams", - "type": "named" - }, - "type": "optional" - } - } - ], - "additionalProperties": false, - "type": "object" + "type": "typeReference" + }, + "type": "body" + }, + "response": { + "type": "json" + }, + "examples": [] }, - "type_:PetOwner": { + "endpoint_order.getAnOrder": { "declaration": { "name": { - "originalName": "PetOwner", + "originalName": "getAnOrder", "camelCase": { - "unsafeName": "petOwner", - "safeName": "petOwner" + "unsafeName": "getAnOrder", + "safeName": "getAnOrder" }, "snakeCase": { - "unsafeName": "pet_owner", - "safeName": "pet_owner" + "unsafeName": "get_an_order", + "safeName": "get_an_order" }, "screamingSnakeCase": { - "unsafeName": "PET_OWNER", - "safeName": "PET_OWNER" + "unsafeName": "GET_AN_ORDER", + "safeName": "GET_AN_ORDER" }, "pascalCase": { - "unsafeName": "PetOwner", - "safeName": "PetOwner" + "unsafeName": "GetAnOrder", + "safeName": "GetAnOrder" } }, "fernFilepath": { - "allParts": [], - "packagePath": [] + "allParts": [ + { + "originalName": "order", + "camelCase": { + "unsafeName": "order", + "safeName": "order" + }, + "snakeCase": { + "unsafeName": "order", + "safeName": "order" + }, + "screamingSnakeCase": { + "unsafeName": "ORDER", + "safeName": "ORDER" + }, + "pascalCase": { + "unsafeName": "Order", + "safeName": "Order" + } + } + ], + "packagePath": [], + "file": { + "originalName": "order", + "camelCase": { + "unsafeName": "order", + "safeName": "order" + }, + "snakeCase": { + "unsafeName": "order", + "safeName": "order" + }, + "screamingSnakeCase": { + "unsafeName": "ORDER", + "safeName": "ORDER" + }, + "pascalCase": { + "unsafeName": "Order", + "safeName": "Order" + } + } } }, - "typeReference": { - "type": "unknown" + "location": { + "method": "GET", + "path": "/order" }, - "type": "alias" + "request": { + "pathParameters": [], + "type": "body" + }, + "response": { + "type": "json" + }, + "examples": [] } }, - "headers": [], - "endpoints": {}, "pathParameters": [] }, "apiPlayground": true, @@ -797,6 +3038,38 @@ "smartCasing": true }, "subpackages": { + "subpackage_pet": { + "fernFilepath": { + "allParts": [ + "pet" + ], + "packagePath": [], + "file": "pet" + }, + "name": "pet", + "service": "service_pet", + "types": [], + "errors": [], + "subpackages": [], + "hasEndpointsInTree": true, + "hasWebSocketInTree": false + }, + "subpackage_order": { + "fernFilepath": { + "allParts": [ + "order" + ], + "packagePath": [], + "file": "order" + }, + "name": "order", + "service": "service_order", + "types": [], + "errors": [], + "subpackages": [], + "hasEndpointsInTree": true, + "hasWebSocketInTree": false + }, "subpackage_petowners": { "fernFilepath": { "allParts": [ @@ -825,13 +3098,23 @@ "packagePath": [] }, "types": [ - "type_:PetOwner" + "type_:Mammal", + "type_:PetStatus", + "type_:Pet", + "type_:PetOwner", + "type_:OrderStatus", + "type_:Order", + "type_:Category", + "type_:Tag", + "type_:User" ], "errors": [], "subpackages": [ + "subpackage_pet", + "subpackage_order", "subpackage_petowners" ], - "hasEndpointsInTree": false, + "hasEndpointsInTree": true, "hasWebSocketInTree": true }, "sdkConfig": { diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/url-reference.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/url-reference.json index f2941416e284..b5d325b4f34e 100644 --- a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/url-reference.json +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/url-reference.json @@ -36,7 +36,36 @@ "name": "Mammal" }, "shape": { - "members": [], + "members": [ + { + "type": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet", + "typeId": "Pet", + "inline": false, + "displayName": "a Pet", + "type": "named" + }, + "docs": "A pet for sale in the pet store" + }, + { + "type": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "User", + "typeId": "User", + "inline": false, + "displayName": "a User", + "type": "named" + }, + "docs": "A User who is purchasing from the pet store" + } + ], "type": "undiscriminatedUnion" }, "autogeneratedExamples": [], @@ -46,7 +75,64 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "Mammal_example_autogenerated": null + "Mammal_example_autogenerated": { + "id": 0, + "category": { + "id": 6, + "name": "name" + }, + "name": "doggie", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1, + "name": "name" + }, + { + "id": 1, + "name": "name" + } + ], + "status": "available" + } + } + } + }, + "PetStatus": { + "name": { + "typeId": "PetStatus", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetStatus" + }, + "shape": { + "values": [ + { + "name": "available" + }, + { + "name": "pending" + }, + { + "name": "sold" + } + ], + "type": "enum" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "docs": "pet status in the store", + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetStatus_example_autogenerated": "available" } } }, @@ -60,22 +146,239 @@ "name": "Pet" }, "shape": { - "aliasOf": { - "type": "unknown" + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetId_example_autogenerated": 1 + } + } + }, + { + "name": "category", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Category", + "typeId": "Category", + "inline": false, + "displayName": "Pet category", + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": { + "PetCategory_example_0": { + "id": 6, + "name": "name" + } + }, + "autogeneratedExamples": {} + } + }, + { + "name": "name", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": { + "PetName_example_0": "doggie" + }, + "autogeneratedExamples": {} + } + }, + { + "name": "photoUrls", + "valueType": { + "container": { + "list": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "list" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetPhotoUrls_example_autogenerated": [ + "string" + ] + } + } + }, + { + "name": "tags", + "valueType": { + "container": { + "optional": { + "container": { + "list": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag", + "typeId": "Tag", + "inline": false, + "displayName": "Pet Tag", + "type": "named" + }, + "type": "list" + }, + "type": "container" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetTags_example_autogenerated": [ + {} + ] + } + } + }, + { + "name": "status", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetStatus", + "typeId": "PetStatus", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "pet status in the store", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetStatus_example_autogenerated": "available" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "docs": "A pet for sale in the pet store", + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": { + "Pet_example_0": { + "id": 0, + "category": { + "id": 6, + "name": "name" + }, + "name": "doggie", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1, + "name": "name" + }, + { + "id": 1, + "name": "name" + } + ], + "status": "available" + } }, - "resolvedType": { - "type": "unknown" + "autogeneratedExamples": {} + } + }, + "PetOwnerStatus": { + "name": { + "typeId": "PetOwnerStatus", + "fernFilepath": { + "allParts": [], + "packagePath": [] }, - "type": "alias" + "name": "PetOwnerStatus" + }, + "shape": { + "values": [ + { + "name": "available" + }, + { + "name": "pending" + }, + { + "name": "sold" + } + ], + "type": "enum" }, "autogeneratedExamples": [], "userProvidedExamples": [], + "docs": "pet status in the store", "referencedTypes": {}, "inline": false, "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "Pet_example_autogenerated": null + "PetOwnerStatus_example_autogenerated": "available" } } }, @@ -89,7 +392,1585 @@ "name": "PetOwner" }, "shape": { - "properties": [], + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerId_example_autogenerated": 1 + } + } + }, + { + "name": "username", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerUsername_example_autogenerated": "string" + } + } + }, + { + "name": "firstName", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerFirstName_example_autogenerated": "string" + } + } + }, + { + "name": "lastName", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerLastName_example_autogenerated": "string" + } + } + }, + { + "name": "email", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerEmail_example_autogenerated": "string" + } + } + }, + { + "name": "password", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerPassword_example_autogenerated": "string" + } + } + }, + { + "name": "phone", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerPhone_example_autogenerated": "string" + } + } + }, + { + "name": "userStatus", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "User Status", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerUserStatus_example_autogenerated": 1 + } + } + }, + { + "name": "category", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetOwnerCategory", + "typeId": "PetOwnerCategory", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "A category for a pet", + "v2Examples": { + "userSpecifiedExamples": { + "PetOwnerCategory_example_0": { + "id": 6, + "name": "name" + } + }, + "autogeneratedExamples": {} + } + }, + { + "name": "name", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": { + "PetOwnerName_example_0": "doggie" + }, + "autogeneratedExamples": {} + } + }, + { + "name": "photoUrls", + "valueType": { + "container": { + "list": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "list" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerPhotoUrls_example_autogenerated": [ + "string" + ] + } + } + }, + { + "name": "tags", + "valueType": { + "container": { + "optional": { + "container": { + "list": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetOwnerTagsItems", + "typeId": "PetOwnerTagsItems", + "inline": false, + "type": "named" + }, + "type": "list" + }, + "type": "container" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerTags_example_autogenerated": [ + {} + ] + } + } + }, + { + "name": "status", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetOwnerStatus", + "typeId": "PetOwnerStatus", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "pet status in the store", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerStatus_example_autogenerated": "available" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "docs": "A tag for a pet", + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": { + "PetOwner_example_0": { + "id": 1, + "username": "username", + "firstName": "firstName", + "lastName": "lastName", + "email": "email", + "password": "password", + "phone": "phone", + "userStatus": 6, + "category": { + "id": 6, + "name": "name" + }, + "name": "name", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1, + "name": "name" + }, + { + "id": 1, + "name": "name" + } + ], + "status": "available" + } + }, + "autogeneratedExamples": {} + } + }, + "OrderStatus": { + "name": { + "typeId": "OrderStatus", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "OrderStatus" + }, + "shape": { + "values": [ + { + "name": "placed" + }, + { + "name": "approved" + }, + { + "name": "delivered" + } + ], + "type": "enum" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "docs": "Order Status", + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "OrderStatus_example_autogenerated": "placed" + } + } + }, + "Order": { + "name": { + "typeId": "Order", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Order" + }, + "shape": { + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "OrderId_example_autogenerated": 1 + } + } + }, + { + "name": "petId", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "OrderPetId_example_autogenerated": 1 + } + } + }, + { + "name": "quantity", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "OrderQuantity_example_autogenerated": 1 + } + } + }, + { + "name": "shipDate", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "DATE_TIME", + "v2": { + "type": "dateTime" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "OrderShipDate_example_autogenerated": "2024-01-15T09:30:00Z" + } + } + }, + { + "name": "status", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "OrderStatus", + "typeId": "OrderStatus", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "Order Status", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "OrderStatus_example_autogenerated": "placed" + } + } + }, + { + "name": "complete", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "default": false, + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "defaultValue": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "OrderComplete_example_autogenerated": false + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "docs": "An order for a pets from the pet store", + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": { + "Order_example_0": { + "id": 0, + "petId": 6, + "quantity": 1, + "shipDate": "2000-01-23T04:56:07.000+00:00", + "status": "placed", + "complete": false + } + }, + "autogeneratedExamples": {} + } + }, + "Category": { + "name": { + "typeId": "Category", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Category" + }, + "shape": { + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CategoryId_example_autogenerated": 1 + } + } + }, + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "CategoryName_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "docs": "A category for a pet", + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": { + "Category_example_0": { + "id": 6, + "name": "name" + } + }, + "autogeneratedExamples": {} + } + }, + "Tag": { + "name": { + "typeId": "Tag", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag" + }, + "shape": { + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "TagId_example_autogenerated": 1 + } + } + }, + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "TagName_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "docs": "A tag for a pet", + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": { + "Tag_example_0": { + "id": 1, + "name": "name" + } + }, + "autogeneratedExamples": {} + } + }, + "User": { + "name": { + "typeId": "User", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "User" + }, + "shape": { + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserId_example_autogenerated": 1 + } + } + }, + { + "name": "username", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserUsername_example_autogenerated": "string" + } + } + }, + { + "name": "firstName", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserFirstName_example_autogenerated": "string" + } + } + }, + { + "name": "lastName", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserLastName_example_autogenerated": "string" + } + } + }, + { + "name": "email", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserEmail_example_autogenerated": "string" + } + } + }, + { + "name": "password", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPassword_example_autogenerated": "string" + } + } + }, + { + "name": "phone", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserPhone_example_autogenerated": "string" + } + } + }, + { + "name": "userStatus", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "docs": "User Status", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "UserUserStatus_example_autogenerated": 1 + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "docs": "A User who is purchasing from the pet store", + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": { + "User_example_0": { + "id": 0, + "username": "username", + "firstName": "firstName", + "lastName": "lastName", + "email": "email", + "password": "password", + "phone": "phone", + "userStatus": 6 + } + }, + "autogeneratedExamples": {} + } + }, + "PetOwnerCategory": { + "name": { + "typeId": "PetOwnerCategory", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetOwnerCategory" + }, + "shape": { + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerCategoryId_example_autogenerated": 1 + } + } + }, + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerCategoryName_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "docs": "A category for a pet", + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": { + "PetOwnerCategory_example_0": { + "id": 6, + "name": "name" + } + }, + "autogeneratedExamples": {} + } + }, + "PetOwnerTagsItems": { + "name": { + "typeId": "PetOwnerTagsItems", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetOwnerTagsItems" + }, + "shape": { + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerTagsItemsId_example_autogenerated": 1 + } + } + }, + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetOwnerTagsItemsName_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "docs": "A tag for a pet", + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": { + "PetOwnerTagsItems_example_0": { + "id": 1, + "name": "name" + } + }, + "autogeneratedExamples": {} + } + }, + "ChannelsPetownersSubscribeParamsData": { + "name": { + "typeId": "ChannelsPetownersSubscribeParamsData", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "ChannelsPetownersSubscribeParamsData" + }, + "shape": { + "properties": [ + { + "name": "petOwner", + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetOwner", + "typeId": "PetOwner", + "displayName": "PetOwner", + "inline": false, + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsPetownersSubscribeParamsDataPetOwner_example_autogenerated": { + "id": 1, + "username": "string", + "firstName": "string", + "lastName": "string", + "email": "string", + "password": "string", + "phone": "string", + "userStatus": 1, + "category": { + "id": 6, + "name": "name" + }, + "name": "doggie", + "photoUrls": [ + "string" + ], + "tags": [ + { + "id": 1, + "name": "string" + } + ], + "status": "available" + } + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsPetownersSubscribeParamsData_example_autogenerated": { + "petOwner": { + "name": "doggie", + "photoUrls": [ + "string" + ] + } + } + } + } + }, + "ChannelsPetownersSubscribeParams": { + "name": { + "typeId": "ChannelsPetownersSubscribeParams", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "ChannelsPetownersSubscribeParams" + }, + "shape": { + "properties": [ + { + "name": "channel", + "valueType": { + "container": { + "optional": { + "container": { + "literal": { + "string": "petowners", + "type": "string" + }, + "type": "literal" + }, + "type": "container" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsPetownersSubscribeParamsChannel_example_autogenerated": "string" + } + } + }, + { + "name": "data", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "ChannelsPetownersSubscribeParamsData", + "typeId": "ChannelsPetownersSubscribeParamsData", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsPetownersSubscribeParamsData_example_autogenerated": { + "petOwner": { + "name": "doggie", + "photoUrls": [ + "string" + ] + } + } + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsPetownersSubscribeParams_example_autogenerated": {} + } + } + }, + "PetownersSubscribe": { + "name": { + "typeId": "PetownersSubscribe", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetownersSubscribe" + }, + "shape": { + "properties": [ + { + "name": "params", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "ChannelsPetownersSubscribeParams", + "typeId": "ChannelsPetownersSubscribeParams", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsPetownersSubscribeParams_example_autogenerated": {} + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "PetownersSubscribe_example_autogenerated": {} + } + } + }, + "ChannelsTestChannelMessagesSendMessageDataData": { + "name": { + "typeId": "ChannelsTestChannelMessagesSendMessageDataData", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "ChannelsTestChannelMessagesSendMessageDataData" + }, + "shape": { + "properties": [ + { + "name": "id", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessageDataDataId_example_autogenerated": "string" + } + } + }, + { + "name": "name", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessageDataDataName_example_autogenerated": "string" + } + } + }, + { + "name": "age", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessageDataDataAge_example_autogenerated": 1 + } + } + }, + { + "name": "address", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessageDataDataAddress_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessageDataData_example_autogenerated": {} + } + } + }, + "ChannelsTestChannelMessagesSendMessageData": { + "name": { + "typeId": "ChannelsTestChannelMessagesSendMessageData", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "ChannelsTestChannelMessagesSendMessageData" + }, + "shape": { + "properties": [ + { + "name": "message", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessageDataMessage_example_autogenerated": "string" + } + } + }, + { + "name": "data", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "ChannelsTestChannelMessagesSendMessageDataData", + "typeId": "ChannelsTestChannelMessagesSendMessageDataData", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessageDataData_example_autogenerated": {} + } + } + } + ], "extends": [], "extendedProperties": [], "extraProperties": false, @@ -102,67 +1983,68 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "PetOwner_example_autogenerated": null - } - } - }, - "Order": { - "name": { - "typeId": "Order", - "fernFilepath": { - "allParts": [], - "packagePath": [] - }, - "name": "Order" - }, - "shape": { - "aliasOf": { - "type": "unknown" - }, - "resolvedType": { - "type": "unknown" - }, - "type": "alias" - }, - "autogeneratedExamples": [], - "userProvidedExamples": [], - "referencedTypes": {}, - "inline": false, - "v2Examples": { - "userSpecifiedExamples": {}, - "autogeneratedExamples": { - "Order_example_autogenerated": null + "ChannelsTestChannelMessagesSendMessageData_example_autogenerated": {} } } }, - "ChannelsPetownersSubscribeParamsData": { + "testChannel_sendMessage": { "name": { - "typeId": "ChannelsPetownersSubscribeParamsData", + "typeId": "testChannel_sendMessage", "fernFilepath": { "allParts": [], "packagePath": [] }, - "name": "ChannelsPetownersSubscribeParamsData" + "name": "testChannel_sendMessage" }, "shape": { "properties": [ { - "name": "petOwner", + "name": "message", "valueType": { - "fernFilepath": { - "allParts": [], - "packagePath": [] + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" }, - "name": "PetOwner", - "typeId": "PetOwner", - "displayName": "PetOwner", - "inline": false, - "type": "named" + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessageMessage_example_autogenerated": "string" + } + } + }, + { + "name": "data", + "valueType": { + "container": { + "optional": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "ChannelsTestChannelMessagesSendMessageData", + "typeId": "ChannelsTestChannelMessagesSendMessageData", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" }, "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsPetownersSubscribeParamsDataPetOwner_example_autogenerated": null + "ChannelsTestChannelMessagesSendMessageData_example_autogenerated": {} } } } @@ -179,36 +2061,34 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsPetownersSubscribeParamsData_example_autogenerated": { - "petOwner": null - } + "testChannel_sendMessage_example_autogenerated": {} } } }, - "ChannelsPetownersSubscribeParams": { + "ChannelsTestChannelMessagesSendMessage2Data": { "name": { - "typeId": "ChannelsPetownersSubscribeParams", + "typeId": "ChannelsTestChannelMessagesSendMessage2Data", "fernFilepath": { "allParts": [], "packagePath": [] }, - "name": "ChannelsPetownersSubscribeParams" + "name": "ChannelsTestChannelMessagesSendMessage2Data" }, "shape": { "properties": [ { - "name": "channel", + "name": "id", "valueType": { "container": { "optional": { - "container": { - "literal": { - "string": "petowners", + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, "type": "string" - }, - "type": "literal" + } }, - "type": "container" + "type": "primitive" }, "type": "optional" }, @@ -217,23 +2097,23 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsPetownersSubscribeParamsChannel_example_autogenerated": "string" + "ChannelsTestChannelMessagesSendMessage2DataId_example_autogenerated": "string" } } }, { - "name": "data", + "name": "name", "valueType": { "container": { "optional": { - "fernFilepath": { - "allParts": [], - "packagePath": [] + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } }, - "name": "ChannelsPetownersSubscribeParamsData", - "typeId": "ChannelsPetownersSubscribeParamsData", - "inline": false, - "type": "named" + "type": "primitive" }, "type": "optional" }, @@ -242,9 +2122,57 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsPetownersSubscribeParamsData_example_autogenerated": { - "petOwner": null - } + "ChannelsTestChannelMessagesSendMessage2DataName_example_autogenerated": "string" + } + } + }, + { + "name": "age", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessage2DataAge_example_autogenerated": 1 + } + } + }, + { + "name": "address", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessage2DataAddress_example_autogenerated": "string" } } } @@ -261,23 +2189,48 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsPetownersSubscribeParams_example_autogenerated": {} + "ChannelsTestChannelMessagesSendMessage2Data_example_autogenerated": {} } } }, - "PetownersSubscribe": { + "testChannel_sendMessage2": { "name": { - "typeId": "PetownersSubscribe", + "typeId": "testChannel_sendMessage2", "fernFilepath": { "allParts": [], "packagePath": [] }, - "name": "PetownersSubscribe" + "name": "testChannel_sendMessage2" }, "shape": { "properties": [ { - "name": "params", + "name": "message", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ChannelsTestChannelMessagesSendMessage2Message_example_autogenerated": "string" + } + } + }, + { + "name": "data", "valueType": { "container": { "optional": { @@ -285,8 +2238,8 @@ "allParts": [], "packagePath": [] }, - "name": "ChannelsPetownersSubscribeParams", - "typeId": "ChannelsPetownersSubscribeParams", + "name": "ChannelsTestChannelMessagesSendMessage2Data", + "typeId": "ChannelsTestChannelMessagesSendMessage2Data", "inline": false, "type": "named" }, @@ -297,7 +2250,7 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "ChannelsPetownersSubscribeParams_example_autogenerated": {} + "ChannelsTestChannelMessagesSendMessage2Data_example_autogenerated": {} } } } @@ -314,7 +2267,7 @@ "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "PetownersSubscribe_example_autogenerated": {} + "testChannel_sendMessage2_example_autogenerated": {} } } } @@ -359,7 +2312,7 @@ "autogeneratedExamples": [ { "example": { - "id": "91844161", + "id": "661615ca", "url": "/pet", "endpointHeaders": [], "endpointPathParameters": [], @@ -367,26 +2320,862 @@ "servicePathParameters": [], "serviceHeaders": [], "rootPathParameters": [], + "request": { + "jsonExample": { + "name": "name", + "photoUrls": [ + "photoUrls", + "photoUrls" + ] + }, + "shape": { + "shape": { + "properties": [ + { + "name": "id", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "category", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "shape": { + "container": { + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Category", + "typeId": "Category", + "inline": false, + "displayName": "Pet category", + "type": "named" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "name", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "jsonExample": "name", + "shape": { + "primitive": { + "string": { + "original": "name" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "photoUrls", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "jsonExample": [ + "photoUrls", + "photoUrls" + ], + "shape": { + "container": { + "list": [ + { + "jsonExample": "photoUrls", + "shape": { + "primitive": { + "string": { + "original": "photoUrls" + }, + "type": "string" + }, + "type": "primitive" + } + }, + { + "jsonExample": "photoUrls", + "shape": { + "primitive": { + "string": { + "original": "photoUrls" + }, + "type": "string" + }, + "type": "primitive" + } + } + ], + "itemType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "list" + }, + "type": "container" + } + } + }, + { + "name": "tags", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "shape": { + "container": { + "valueType": { + "container": { + "list": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag", + "typeId": "Tag", + "inline": false, + "displayName": "Pet Tag", + "type": "named" + }, + "type": "list" + }, + "type": "container" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "status", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "shape": { + "container": { + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetStatus", + "typeId": "PetStatus", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "type": "named" + }, + "type": "reference" + }, "response": { "value": { "value": { "jsonExample": { - "key": "value" + "id": 1000000, + "category": { + "id": 1000000, + "name": "name" + }, + "name": "name", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1000000, + "name": "name" + }, + { + "id": 1000000, + "name": "name" + } + ], + "status": "available" }, "shape": { "shape": { - "value": { - "jsonExample": { - "key": "value" + "properties": [ + { + "name": "id", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "jsonExample": 1000000, + "shape": { + "container": { + "optional": { + "jsonExample": 1000000, + "shape": { + "primitive": { + "long": 1000000, + "type": "long" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } }, - "shape": { - "unknown": { - "key": "value" + { + "name": "category", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "jsonExample": { + "id": 1000000, + "name": "name" + }, + "shape": { + "container": { + "optional": { + "jsonExample": { + "id": 1000000, + "name": "name" + }, + "shape": { + "shape": { + "properties": [ + { + "name": "id", + "originalTypeDeclaration": { + "typeId": "Category", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Category" + }, + "value": { + "jsonExample": 1000000, + "shape": { + "container": { + "optional": { + "jsonExample": 1000000, + "shape": { + "primitive": { + "long": 1000000, + "type": "long" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "name", + "originalTypeDeclaration": { + "typeId": "Category", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Category" + }, + "value": { + "jsonExample": "name", + "shape": { + "container": { + "optional": { + "jsonExample": "name", + "shape": { + "primitive": { + "string": { + "original": "name" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "Category", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Category" + }, + "type": "named" + } + }, + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Category", + "typeId": "Category", + "inline": false, + "displayName": "Pet category", + "type": "named" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "name", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "jsonExample": "name", + "shape": { + "primitive": { + "string": { + "original": "name" + }, + "type": "string" + }, + "type": "primitive" + } + } + }, + { + "name": "photoUrls", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "jsonExample": [ + "photoUrls", + "photoUrls" + ], + "shape": { + "container": { + "list": [ + { + "jsonExample": "photoUrls", + "shape": { + "primitive": { + "string": { + "original": "photoUrls" + }, + "type": "string" + }, + "type": "primitive" + } + }, + { + "jsonExample": "photoUrls", + "shape": { + "primitive": { + "string": { + "original": "photoUrls" + }, + "type": "string" + }, + "type": "primitive" + } + } + ], + "itemType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "list" + }, + "type": "container" + } + } + }, + { + "name": "tags", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" + }, + "value": { + "jsonExample": [ + { + "id": 1000000, + "name": "name" + }, + { + "id": 1000000, + "name": "name" + } + ], + "shape": { + "container": { + "optional": { + "jsonExample": [ + { + "id": 1000000, + "name": "name" + }, + { + "id": 1000000, + "name": "name" + } + ], + "shape": { + "container": { + "list": [ + { + "jsonExample": { + "id": 1000000, + "name": "name" + }, + "shape": { + "shape": { + "properties": [ + { + "name": "id", + "originalTypeDeclaration": { + "typeId": "Tag", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag" + }, + "value": { + "jsonExample": 1000000, + "shape": { + "container": { + "optional": { + "jsonExample": 1000000, + "shape": { + "primitive": { + "long": 1000000, + "type": "long" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "name", + "originalTypeDeclaration": { + "typeId": "Tag", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag" + }, + "value": { + "jsonExample": "name", + "shape": { + "container": { + "optional": { + "jsonExample": "name", + "shape": { + "primitive": { + "string": { + "original": "name" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "Tag", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag" + }, + "type": "named" + } + }, + { + "jsonExample": { + "id": 1000000, + "name": "name" + }, + "shape": { + "shape": { + "properties": [ + { + "name": "id", + "originalTypeDeclaration": { + "typeId": "Tag", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag" + }, + "value": { + "jsonExample": 1000000, + "shape": { + "container": { + "optional": { + "jsonExample": 1000000, + "shape": { + "primitive": { + "long": 1000000, + "type": "long" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "name", + "originalTypeDeclaration": { + "typeId": "Tag", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag" + }, + "value": { + "jsonExample": "name", + "shape": { + "container": { + "optional": { + "jsonExample": "name", + "shape": { + "primitive": { + "string": { + "original": "name" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "Tag", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag" + }, + "type": "named" + } + } + ], + "itemType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag", + "typeId": "Tag", + "inline": false, + "displayName": "Pet Tag", + "type": "named" + }, + "type": "list" + }, + "type": "container" + } + }, + "valueType": { + "container": { + "list": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Tag", + "typeId": "Tag", + "inline": false, + "displayName": "Pet Tag", + "type": "named" + }, + "type": "list" + }, + "type": "container" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "status", + "originalTypeDeclaration": { + "typeId": "Pet", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet" }, - "type": "unknown" + "value": { + "jsonExample": "available", + "shape": { + "container": { + "optional": { + "jsonExample": "available", + "shape": { + "shape": { + "value": "available", + "type": "enum" + }, + "typeName": { + "typeId": "PetStatus", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetStatus" + }, + "type": "named" + } + }, + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "PetStatus", + "typeId": "PetStatus", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + } + } } - }, - "type": "alias" + ], + "type": "object" }, "typeName": { "typeId": "Pet", @@ -418,7 +3207,142 @@ "audiences": [], "id": "endpoint_pet.updateAnExistingPet", "name": "updateAnExistingPet", - "v2RequestBodies": {}, + "requestBody": { + "contentType": "application/json", + "docs": "Pet object that needs to be added to the store", + "requestBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet", + "typeId": "Pet", + "inline": false, + "displayName": "a Pet", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "petUpdateAnExistingPetExample": { + "id": 0, + "category": { + "id": 6, + "name": "name" + }, + "name": "doggie", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1, + "name": "name" + }, + { + "id": 1, + "name": "name" + } + ], + "status": "available" + } + } + }, + "type": "reference" + }, + "v2RequestBodies": { + "requestBodies": [ + { + "contentType": "application/json", + "docs": "Pet object that needs to be added to the store", + "requestBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet", + "typeId": "Pet", + "inline": false, + "displayName": "a Pet", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "petUpdateAnExistingPetExample": { + "id": 0, + "category": { + "id": 6, + "name": "name" + }, + "name": "doggie", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1, + "name": "name" + }, + { + "id": 1, + "name": "name" + } + ], + "status": "available" + } + } + }, + "type": "reference" + }, + { + "contentType": "application/xml", + "docs": "Pet object that needs to be added to the store", + "requestBodyType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Pet", + "typeId": "Pet", + "inline": false, + "displayName": "a Pet", + "type": "named" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "petUpdateAnExistingPetExample": { + "id": 0, + "category": { + "id": 6, + "name": "name" + }, + "name": "doggie", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1, + "name": "name" + }, + { + "id": 1, + "name": "name" + } + ], + "status": "available" + } + } + }, + "type": "reference" + } + ] + }, "response": { "statusCode": 200, "body": { @@ -431,14 +3355,36 @@ "name": "Pet", "typeId": "Pet", "inline": false, - "displayName": "Pet", + "displayName": "a Pet", "type": "named" }, "docs": "A list of pets", "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "petUpdateAnExistingPetExample": null + "petUpdateAnExistingPetExample": { + "id": 0, + "category": { + "id": 6, + "name": "name" + }, + "name": "doggie", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1, + "name": "name" + }, + { + "id": 1, + "name": "name" + } + ], + "status": "available" + } } }, "type": "response" @@ -449,8 +3395,8 @@ }, "v2Examples": { "autogeneratedExamples": { - "base_petUpdateAnExistingPetExample_200": { - "displayName": "updateAnExistingPetExample", + "petUpdateAnExistingPetExample_200": { + "displayName": "petUpdateAnExistingPetExample", "request": { "endpoint": { "method": "PUT", @@ -458,12 +3404,57 @@ }, "pathParameters": {}, "queryParameters": {}, - "headers": {} + "headers": {}, + "requestBody": { + "id": 0, + "category": { + "id": 6, + "name": "name" + }, + "name": "doggie", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1, + "name": "name" + }, + { + "id": 1, + "name": "name" + } + ], + "status": "available" + } }, "response": { "statusCode": 200, "body": { - "value": null, + "value": { + "id": 0, + "category": { + "id": 6, + "name": "name" + }, + "name": "doggie", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1, + "name": "name" + }, + { + "id": 1, + "name": "name" + } + ], + "status": "available" + }, "type": "json" } } @@ -485,14 +3476,36 @@ "name": "Pet", "typeId": "Pet", "inline": false, - "displayName": "Pet", + "displayName": "a Pet", "type": "named" }, "docs": "A list of pets", "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "petUpdateAnExistingPetExample": null + "petUpdateAnExistingPetExample": { + "id": 0, + "category": { + "id": 6, + "name": "name" + }, + "name": "doggie", + "photoUrls": [ + "photoUrls", + "photoUrls" + ], + "tags": [ + { + "id": 1, + "name": "name" + }, + { + "id": 1, + "name": "name" + } + ], + "status": "available" + } } }, "type": "response" @@ -541,7 +3554,7 @@ "autogeneratedExamples": [ { "example": { - "id": "eef2efd3", + "id": "984010f", "url": "/order", "endpointHeaders": [], "endpointPathParameters": [], @@ -553,22 +3566,266 @@ "value": { "value": { "jsonExample": { - "key": "value" + "id": 1000000, + "petId": 1000000, + "quantity": 1, + "shipDate": "2024-01-15T09:30:00Z", + "status": "placed", + "complete": true }, "shape": { "shape": { - "value": { - "jsonExample": { - "key": "value" + "properties": [ + { + "name": "id", + "originalTypeDeclaration": { + "typeId": "Order", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Order" + }, + "value": { + "jsonExample": 1000000, + "shape": { + "container": { + "optional": { + "jsonExample": 1000000, + "shape": { + "primitive": { + "long": 1000000, + "type": "long" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } }, - "shape": { - "unknown": { - "key": "value" + { + "name": "petId", + "originalTypeDeclaration": { + "typeId": "Order", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Order" + }, + "value": { + "jsonExample": 1000000, + "shape": { + "container": { + "optional": { + "jsonExample": 1000000, + "shape": { + "primitive": { + "long": 1000000, + "type": "long" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "LONG", + "v2": { + "validation": {}, + "type": "long" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "quantity", + "originalTypeDeclaration": { + "typeId": "Order", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Order" + }, + "value": { + "jsonExample": 1, + "shape": { + "container": { + "optional": { + "jsonExample": 1, + "shape": { + "primitive": { + "integer": 1, + "type": "integer" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "INTEGER", + "v2": { + "validation": {}, + "type": "integer" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "shipDate", + "originalTypeDeclaration": { + "typeId": "Order", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Order" + }, + "value": { + "jsonExample": "2024-01-15T09:30:00Z", + "shape": { + "container": { + "optional": { + "jsonExample": "2024-01-15T09:30:00Z", + "shape": { + "primitive": { + "datetime": "2024-01-15T09:30:00.000Z", + "raw": "2024-01-15T09:30:00Z", + "type": "datetime" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "DATE_TIME", + "v2": { + "type": "dateTime" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "status", + "originalTypeDeclaration": { + "typeId": "Order", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Order" + }, + "value": { + "jsonExample": "placed", + "shape": { + "container": { + "optional": { + "jsonExample": "placed", + "shape": { + "shape": { + "value": "placed", + "type": "enum" + }, + "typeName": { + "typeId": "OrderStatus", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "OrderStatus" + }, + "type": "named" + } + }, + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "OrderStatus", + "typeId": "OrderStatus", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "complete", + "originalTypeDeclaration": { + "typeId": "Order", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Order" }, - "type": "unknown" + "value": { + "jsonExample": true, + "shape": { + "container": { + "optional": { + "jsonExample": true, + "shape": { + "primitive": { + "boolean": true, + "type": "boolean" + }, + "type": "primitive" + } + }, + "valueType": { + "primitive": { + "v1": "BOOLEAN", + "v2": { + "default": false, + "type": "boolean" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } } - }, - "type": "alias" + ], + "type": "object" }, "typeName": { "typeId": "Order", @@ -613,14 +3870,21 @@ "name": "Order", "typeId": "Order", "inline": false, - "displayName": "Order", + "displayName": "Pet Order", "type": "named" }, "docs": "An order object", "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "orderGetAnOrderExample": null + "orderGetAnOrderExample": { + "id": 0, + "petId": 6, + "quantity": 1, + "shipDate": "2000-01-23T04:56:07.000+00:00", + "status": "placed", + "complete": false + } } }, "type": "response" @@ -645,7 +3909,14 @@ "response": { "statusCode": 200, "body": { - "value": null, + "value": { + "id": 0, + "petId": 6, + "quantity": 1, + "shipDate": "2000-01-23T04:56:07.000+00:00", + "status": "placed", + "complete": false + }, "type": "json" } } @@ -667,14 +3938,21 @@ "name": "Order", "typeId": "Order", "inline": false, - "displayName": "Order", + "displayName": "Pet Order", "type": "named" }, "docs": "An order object", "v2Examples": { "userSpecifiedExamples": {}, "autogeneratedExamples": { - "orderGetAnOrderExample": null + "orderGetAnOrderExample": { + "id": 0, + "petId": 6, + "quantity": 1, + "shipDate": "2000-01-23T04:56:07.000+00:00", + "status": "placed", + "complete": false + } } }, "type": "response" @@ -968,14 +4246,178 @@ "pathParameters": [], "headers": [], "queryParameters": [], - "messages": [], + "messages": [ + { + "type": "send", + "body": { + "jsonExample": {}, + "shape": { + "shape": { + "properties": [ + { + "name": "message", + "originalTypeDeclaration": { + "typeId": "testChannel_sendMessage", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "testChannel_sendMessage" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "data", + "originalTypeDeclaration": { + "typeId": "testChannel_sendMessage", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "testChannel_sendMessage" + }, + "value": { + "shape": { + "container": { + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "ChannelsTestChannelMessagesSendMessageData", + "typeId": "ChannelsTestChannelMessagesSendMessageData", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "testChannel_sendMessage", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "testChannel_sendMessage" + }, + "type": "named" + }, + "type": "reference" + } + } + ], "url": "/test" }, { "pathParameters": [], "headers": [], "queryParameters": [], - "messages": [], + "messages": [ + { + "type": "send", + "body": { + "jsonExample": {}, + "shape": { + "shape": { + "properties": [ + { + "name": "message", + "originalTypeDeclaration": { + "typeId": "testChannel_sendMessage", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "testChannel_sendMessage" + }, + "value": { + "shape": { + "container": { + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + } + } + }, + { + "name": "data", + "originalTypeDeclaration": { + "typeId": "testChannel_sendMessage", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "testChannel_sendMessage" + }, + "value": { + "shape": { + "container": { + "valueType": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "ChannelsTestChannelMessagesSendMessageData", + "typeId": "ChannelsTestChannelMessagesSendMessageData", + "inline": false, + "type": "named" + }, + "type": "optional" + }, + "type": "container" + } + } + } + ], + "type": "object" + }, + "typeName": { + "typeId": "testChannel_sendMessage", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "testChannel_sendMessage" + }, + "type": "named" + }, + "type": "reference" + } + } + ], "url": "/test" } ], @@ -991,7 +4433,12 @@ "Pet", "PetOwner", "Order", - "PetOwner" + "Category", + "Tag", + "User", + "PetOwner", + "testChannel_sendMessage", + "testChannel_sendMessage2" ], "errors": [], "subpackages": [ From 4009024e80d30d5abce2ee689e4a13e9bdd41ddf Mon Sep 17 00:00:00 2001 From: jsklan <100491078+jsklan@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:08:51 -0400 Subject: [PATCH 07/14] fix(internal): Update ir-to-jsonschema snapshots (#16197) Update snapshots --- .../allof-inline/type__PlantBase.json | 55 +++++++++++++++ .../type__PlantBaseWateringFrequency.json | 10 +++ .../type__PlantPostSunExposure.json | 10 +++ .../type__PlantPostWateringFrequency.json | 10 +++ .../allof-inline/type__PlantStrict.json | 24 +++++++ .../allof-inline/type__TreeBase.json | 58 +++++++++++++++ .../allof-inline/type__TreeDescribable.json | 29 ++++++++ .../allof-inline/type__TreeIdentifiable.json | 14 ++++ .../allof-inline/type__TreeRecord.json | 58 +++++++++++++++ .../__snapshots__/allof/type__PlantBase.json | 55 +++++++++++++++ .../type__PlantBaseWateringFrequency.json | 10 +++ .../allof/type__PlantPostSunExposure.json | 10 +++ .../allof/type__PlantStrict.json | 24 +++++++ .../__snapshots__/allof/type__TreeBase.json | 58 +++++++++++++++ .../allof/type__TreeDescribable.json | 29 ++++++++ .../allof/type__TreeIdentifiable.json | 14 ++++ .../__snapshots__/allof/type__TreeRecord.json | 70 +++++++++++++++++++ 17 files changed, 538 insertions(+) create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantBase.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantBaseWateringFrequency.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantPostSunExposure.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantPostWateringFrequency.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantStrict.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeBase.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeDescribable.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeIdentifiable.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeRecord.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantBase.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantBaseWateringFrequency.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantPostSunExposure.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantStrict.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeBase.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeDescribable.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeIdentifiable.json create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeRecord.json diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantBase.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantBase.json new file mode 100644 index 000000000000..2c237c8a4157 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantBase.json @@ -0,0 +1,55 @@ +{ + "type": "object", + "properties": { + "species": { + "type": "string", + "description": "The botanical species name." + }, + "family": { + "type": "string", + "description": "The botanical family." + }, + "genus": { + "type": "string", + "description": "The botanical genus." + }, + "commonName": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The common name of the plant." + }, + "wateringFrequency": { + "oneOf": [ + { + "$ref": "#/definitions/PlantBaseWateringFrequency" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "species", + "family", + "genus" + ], + "additionalProperties": false, + "definitions": { + "PlantBaseWateringFrequency": { + "type": "string", + "enum": [ + "daily", + "weekly", + "biweekly", + "monthly" + ] + } + } +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantBaseWateringFrequency.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantBaseWateringFrequency.json new file mode 100644 index 000000000000..0f431750d0d2 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantBaseWateringFrequency.json @@ -0,0 +1,10 @@ +{ + "type": "string", + "enum": [ + "daily", + "weekly", + "biweekly", + "monthly" + ], + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantPostSunExposure.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantPostSunExposure.json new file mode 100644 index 000000000000..3156d68b0c81 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantPostSunExposure.json @@ -0,0 +1,10 @@ +{ + "type": "string", + "enum": [ + "full", + "partial", + "shade" + ], + "description": "Required sun exposure level.", + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantPostWateringFrequency.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantPostWateringFrequency.json new file mode 100644 index 000000000000..0f431750d0d2 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantPostWateringFrequency.json @@ -0,0 +1,10 @@ +{ + "type": "string", + "enum": [ + "daily", + "weekly", + "biweekly", + "monthly" + ], + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantStrict.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantStrict.json new file mode 100644 index 000000000000..64018b03014b --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__PlantStrict.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "properties": { + "species": { + "type": "string", + "description": "The botanical species name." + }, + "family": { + "type": "string", + "description": "The botanical family." + }, + "genus": { + "type": "string", + "description": "The botanical genus." + } + }, + "required": [ + "species", + "family", + "genus" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeBase.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeBase.json new file mode 100644 index 000000000000..63902f8672cd --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeBase.json @@ -0,0 +1,58 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tree identifier." + }, + "treeName": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the tree." + }, + "treeDescription": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A description of the tree." + }, + "treeSpecies": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The species of tree." + }, + "heightInFeet": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Height of the tree in feet." + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeDescribable.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeDescribable.json new file mode 100644 index 000000000000..de2f20f4d44a --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeDescribable.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "properties": { + "treeName": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the tree." + }, + "treeDescription": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A description of the tree." + } + }, + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeIdentifiable.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeIdentifiable.json new file mode 100644 index 000000000000..81f066352263 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeIdentifiable.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tree identifier." + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeRecord.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeRecord.json new file mode 100644 index 000000000000..201ccee5a15f --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof-inline/type__TreeRecord.json @@ -0,0 +1,58 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tree identifier." + }, + "treeName": { + "type": "string", + "description": "Display name of the tree." + }, + "treeDescription": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A description of the tree." + }, + "treeSpecies": { + "type": "string", + "description": "The species of tree." + }, + "heightInFeet": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Height of the tree in feet." + }, + "plantedDate": { + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "description": "Date the tree was planted." + } + }, + "required": [ + "id", + "treeName", + "treeSpecies" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantBase.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantBase.json new file mode 100644 index 000000000000..2c237c8a4157 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantBase.json @@ -0,0 +1,55 @@ +{ + "type": "object", + "properties": { + "species": { + "type": "string", + "description": "The botanical species name." + }, + "family": { + "type": "string", + "description": "The botanical family." + }, + "genus": { + "type": "string", + "description": "The botanical genus." + }, + "commonName": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The common name of the plant." + }, + "wateringFrequency": { + "oneOf": [ + { + "$ref": "#/definitions/PlantBaseWateringFrequency" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "species", + "family", + "genus" + ], + "additionalProperties": false, + "definitions": { + "PlantBaseWateringFrequency": { + "type": "string", + "enum": [ + "daily", + "weekly", + "biweekly", + "monthly" + ] + } + } +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantBaseWateringFrequency.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantBaseWateringFrequency.json new file mode 100644 index 000000000000..0f431750d0d2 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantBaseWateringFrequency.json @@ -0,0 +1,10 @@ +{ + "type": "string", + "enum": [ + "daily", + "weekly", + "biweekly", + "monthly" + ], + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantPostSunExposure.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantPostSunExposure.json new file mode 100644 index 000000000000..3156d68b0c81 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantPostSunExposure.json @@ -0,0 +1,10 @@ +{ + "type": "string", + "enum": [ + "full", + "partial", + "shade" + ], + "description": "Required sun exposure level.", + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantStrict.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantStrict.json new file mode 100644 index 000000000000..64018b03014b --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__PlantStrict.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "properties": { + "species": { + "type": "string", + "description": "The botanical species name." + }, + "family": { + "type": "string", + "description": "The botanical family." + }, + "genus": { + "type": "string", + "description": "The botanical genus." + } + }, + "required": [ + "species", + "family", + "genus" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeBase.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeBase.json new file mode 100644 index 000000000000..63902f8672cd --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeBase.json @@ -0,0 +1,58 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tree identifier." + }, + "treeName": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the tree." + }, + "treeDescription": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A description of the tree." + }, + "treeSpecies": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The species of tree." + }, + "heightInFeet": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Height of the tree in feet." + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeDescribable.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeDescribable.json new file mode 100644 index 000000000000..de2f20f4d44a --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeDescribable.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "properties": { + "treeName": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the tree." + }, + "treeDescription": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A description of the tree." + } + }, + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeIdentifiable.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeIdentifiable.json new file mode 100644 index 000000000000..81f066352263 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeIdentifiable.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tree identifier." + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeRecord.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeRecord.json new file mode 100644 index 000000000000..6d4930afeef6 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/allof/type__TreeRecord.json @@ -0,0 +1,70 @@ +{ + "type": "object", + "properties": { + "treeSpecies": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The species of tree." + }, + "heightInFeet": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Height of the tree in feet." + }, + "id": { + "type": "string", + "description": "Unique tree identifier." + }, + "treeName": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the tree." + }, + "treeDescription": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A description of the tree." + }, + "plantedDate": { + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "description": "Date the tree was planted." + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file From 88665448547fcae23be7d254a1aa6138971d888b Mon Sep 17 00:00:00 2001 From: jsklan <100491078+jsklan@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:31:28 -0400 Subject: [PATCH 08/14] feat(cli): emit npm publishing workflows in generated CLIs (#16167) * feat(cli): emit npm publishing workflows in generated CLIs When output mode is github with npm publishInfo, the CLI generator now emits .github/workflows/ci.yml containing: - check/compile/test jobs on every push - cross-platform binary matrix (x86_64/aarch64 linux, darwin, windows) - per-platform embedded-binary npm packages + thin launcher - npm publish gated on tag push, authed via secrets.NPM_TOKEN Also stamps the resolved version into Cargo.toml [package] version (safe for cargo build --locked since only [package] version changes). Resolves FER-10875 * style: format with biome * fix(cli): also patch Cargo.lock version to match Cargo.toml for --locked compat * fix(cli): use assertNeverNoThrow in resolveOutputConfig switch defaults * fix(cli): update pnpm-lock.yaml for core-utils dependency * fix(cli): guard empty token env var, add OIDC support, add standalone tests - resolveNpmPublishInfo now falls back to NPM_TOKEN when tokenEnvironmentVariable is empty (fixes broken workflow YAML in local generation mode) - Add useOidc flag to ResolvedNpmPublishInfo; emitPublishWorkflow renders OIDC permissions and omits NODE_AUTH_TOKEN when active - Add resolveOutputConfig.test.ts (7 tests): github/downloadFiles modes, empty token fallback, OIDC sentinel, shouldGenerate: false, non-npm publish type - Add emitPublishWorkflow.test.ts (8 tests): standard token, custom token, OIDC mode, empty-token guard, structural checks * style(cli): fix biome formatting * style(cli): fix biome lint errors in resolveOutputConfig test * fix(cli): harden emitted npm publish workflow - fail-fast: false on the publish matrix so a transient single-platform failure can't leave npm with some platform packages published and no launcher (the already-published versions reject re-publish). - Require the semver "-" separator for pre-release dist-tag detection (*-alpha* / *-beta*) so a release tag for a package whose version string happens to contain "alpha"/"beta" isn't mis-tagged on npm. - Derive the launcher's PLATFORMS map and optionalDependencies lines from TARGETS so adding a target updates the matrix, the platform packages, and the launcher in one place. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: patrick thornton <70873350+patrickthornton@users.noreply.github.com> Co-authored-by: patrick thornton --- generators/cli/package.json | 1 + .../unreleased/emit-npm-publish-workflow.yml | 6 + .../src/__test__/emitPublishWorkflow.test.ts | 124 + .../cli/src/__test__/patchCargoToml.test.ts | 59 +- .../src/__test__/resolveOutputConfig.test.ts | 143 + .../cli/src/__test__/runPipeline.test.ts | 106 +- generators/cli/src/cli.ts | 5 +- generators/cli/src/emitPublishWorkflow.ts | 299 + generators/cli/src/index.ts | 8 +- generators/cli/src/patchCargoToml.ts | 51 +- generators/cli/src/resolveOutputConfig.ts | 106 + generators/cli/src/runPipeline.ts | 17 +- pnpm-lock.yaml | 3 + seed/cli/allof-inline/Cargo.lock | 2 +- seed/cli/allof-inline/Cargo.toml | 2 +- seed/cli/allof/Cargo.lock | 2 +- seed/cli/allof/Cargo.toml | 2 +- .../Cargo.lock | 2 +- .../Cargo.toml | 2 +- .../no-custom-config/Cargo.lock | 2 +- .../no-custom-config/Cargo.toml | 2 +- .../no-custom-config/Cargo.lock | 2 +- .../no-custom-config/Cargo.toml | 2 +- seed/cli/file-upload-openapi/Cargo.lock | 2 +- seed/cli/file-upload-openapi/Cargo.toml | 2 +- seed/cli/imdb/Cargo.lock | 2 +- seed/cli/imdb/Cargo.toml | 2 +- .../Cargo.lock | 2 +- .../Cargo.toml | 2 +- seed/cli/no-content-response/Cargo.lock | 2 +- seed/cli/no-content-response/Cargo.toml | 2 +- seed/cli/null-type/Cargo.lock | 2 +- seed/cli/null-type/Cargo.toml | 2 +- seed/cli/nullable-allof-extends/Cargo.lock | 2 +- seed/cli/nullable-allof-extends/Cargo.toml | 2 +- seed/cli/nullable-request-body/Cargo.lock | 2 +- seed/cli/nullable-request-body/Cargo.toml | 2 +- .../Cargo.lock | 2 +- .../Cargo.toml | 2 +- seed/cli/openapi-request-body-ref/Cargo.lock | 2 +- seed/cli/openapi-request-body-ref/Cargo.toml | 2 +- seed/cli/query-param-name-conflict/Cargo.lock | 2 +- seed/cli/query-param-name-conflict/Cargo.toml | 2 +- .../Cargo.lock | 2 +- .../Cargo.toml | 2 +- .../github-npm/.github/workflows/ci.yml | 243 + .../github-npm/.gitignore | 4 + .../github-npm/Cargo.lock | 2817 ++++++ .../github-npm/Cargo.toml | 85 + .../github-npm/LICENSE | 202 + .../cli/query-parameters-api/main.rs | 14 + .../cli/query-parameters-api/openapi0.json | 1 + .../github-npm/dist-workspace.toml | 34 + .../github-npm/src/app.rs | 851 ++ .../github-npm/src/arg_source.rs | 229 + .../github-npm/src/auth/builder.rs | 861 ++ .../github-npm/src/auth/compose.rs | 589 ++ .../github-npm/src/auth/credential.rs | 549 ++ .../github-npm/src/auth/error.rs | 190 + .../github-npm/src/auth/mod.rs | 61 + .../github-npm/src/auth/oauth2.rs | 1210 +++ .../github-npm/src/auth/provider.rs | 192 + .../github-npm/src/auth/root_builder.rs | 419 + .../github-npm/src/auth/schemes.rs | 433 + .../github-npm/src/auth/test_helpers.rs | 53 + .../github-npm/src/binding.rs | 119 + .../github-npm/src/cli_args.rs | 352 + .../github-npm/src/completions.rs | 175 + .../github-npm/src/custom_commands.rs | 298 + .../github-npm/src/early_intercept.rs | 185 + .../github-npm/src/error.rs | 467 + .../github-npm/src/formatter.rs | 944 ++ .../github-npm/src/graphql/app.rs | 482 + .../github-npm/src/graphql/binding.rs | 355 + .../github-npm/src/graphql/commands.rs | 394 + .../github-npm/src/graphql/discovery.rs | 145 + .../github-npm/src/graphql/executor.rs | 909 ++ .../github-npm/src/graphql/help.rs | 373 + .../github-npm/src/graphql/mod.rs | 12 + .../github-npm/src/graphql/parser.rs | 974 ++ .../github-npm/src/hooks.rs | 297 + .../github-npm/src/http.rs | 845 ++ .../github-npm/src/lib.rs | 68 + .../github-npm/src/logging.rs | 123 + .../github-npm/src/man.rs | 101 + .../github-npm/src/openapi/app.rs | 3656 +++++++ .../github-npm/src/openapi/binding.rs | 597 ++ .../github-npm/src/openapi/commands.rs | 1563 +++ .../github-npm/src/openapi/discovery.rs | 1129 +++ .../github-npm/src/openapi/executor.rs | 6847 +++++++++++++ .../github-npm/src/openapi/help.rs | 530 + .../github-npm/src/openapi/mod.rs | 15 + .../github-npm/src/openapi/overlay.rs | 1829 ++++ .../github-npm/src/openapi/parser.rs | 8592 +++++++++++++++++ .../github-npm/src/openapi/skill_emitter.rs | 731 ++ .../github-npm/src/output.rs | 231 + .../github-npm/src/stability.rs | 127 + .../github-npm/src/text.rs | 327 + .../github-npm/src/validate.rs | 839 ++ .../github-npm/src/websocket/auth.rs | 532 + .../github-npm/src/websocket/client.rs | 667 ++ .../github-npm/src/websocket/error.rs | 247 + .../github-npm/src/websocket/mod.rs | 48 + .../no-custom-config/Cargo.lock | 2 +- .../no-custom-config/Cargo.toml | 2 +- .../Cargo.lock | 2 +- .../Cargo.toml | 2 +- seed/cli/seed.yml | 12 + .../cli/server-sent-events-openapi/Cargo.lock | 2 +- .../cli/server-sent-events-openapi/Cargo.toml | 2 +- seed/cli/server-url-templating/Cargo.lock | 2 +- seed/cli/server-url-templating/Cargo.toml | 2 +- seed/cli/url-form-encoded/Cargo.lock | 2 +- seed/cli/url-form-encoded/Cargo.toml | 2 +- seed/cli/webhook-audience/Cargo.lock | 2 +- seed/cli/webhook-audience/Cargo.toml | 2 +- seed/cli/x-fern-default/Cargo.lock | 2 +- seed/cli/x-fern-default/Cargo.toml | 2 +- 118 files changed, 46121 insertions(+), 73 deletions(-) create mode 100644 generators/cli/sdk/changes/unreleased/emit-npm-publish-workflow.yml create mode 100644 generators/cli/src/__test__/emitPublishWorkflow.test.ts create mode 100644 generators/cli/src/__test__/resolveOutputConfig.test.ts create mode 100644 generators/cli/src/emitPublishWorkflow.ts create mode 100644 generators/cli/src/resolveOutputConfig.ts create mode 100644 seed/cli/query-parameters-openapi/github-npm/.github/workflows/ci.yml create mode 100644 seed/cli/query-parameters-openapi/github-npm/.gitignore create mode 100644 seed/cli/query-parameters-openapi/github-npm/Cargo.lock create mode 100644 seed/cli/query-parameters-openapi/github-npm/Cargo.toml create mode 100644 seed/cli/query-parameters-openapi/github-npm/LICENSE create mode 100644 seed/cli/query-parameters-openapi/github-npm/cli/query-parameters-api/main.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/cli/query-parameters-api/openapi0.json create mode 100644 seed/cli/query-parameters-openapi/github-npm/dist-workspace.toml create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/app.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/arg_source.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/auth/builder.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/auth/compose.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/auth/credential.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/auth/error.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/auth/mod.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/auth/oauth2.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/auth/provider.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/auth/root_builder.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/auth/schemes.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/auth/test_helpers.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/binding.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/cli_args.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/completions.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/custom_commands.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/early_intercept.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/error.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/formatter.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/graphql/app.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/graphql/binding.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/graphql/commands.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/graphql/discovery.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/graphql/executor.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/graphql/help.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/graphql/mod.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/graphql/parser.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/hooks.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/http.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/lib.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/logging.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/man.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/openapi/app.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/openapi/binding.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/openapi/commands.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/openapi/discovery.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/openapi/executor.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/openapi/help.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/openapi/mod.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/openapi/overlay.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/openapi/skill_emitter.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/output.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/stability.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/text.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/validate.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/websocket/auth.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/websocket/client.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/websocket/error.rs create mode 100644 seed/cli/query-parameters-openapi/github-npm/src/websocket/mod.rs diff --git a/generators/cli/package.json b/generators/cli/package.json index 95320c14fc36..a94b23470bc7 100644 --- a/generators/cli/package.json +++ b/generators/cli/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@fern-api/base-generator": "workspace:*", "@fern-api/configs": "workspace:*", + "@fern-api/core-utils": "workspace:*", "@fern-api/fs-utils": "workspace:*", "@fern-fern/ir-sdk": "67.0.0", "@types/node": "catalog:", diff --git a/generators/cli/sdk/changes/unreleased/emit-npm-publish-workflow.yml b/generators/cli/sdk/changes/unreleased/emit-npm-publish-workflow.yml new file mode 100644 index 000000000000..3dc7c8d44416 --- /dev/null +++ b/generators/cli/sdk/changes/unreleased/emit-npm-publish-workflow.yml @@ -0,0 +1,6 @@ +- summary: | + Emit npm publishing workflow (.github/workflows/ci.yml) in github output mode. + Cross-platform binary matrix builds per-OS/arch npm packages with an embedded + binary and a thin Node.js launcher, gated on tag push with NPM_TOKEN auth. + Stamp resolved version into Cargo.toml and npm manifests. + type: feat diff --git a/generators/cli/src/__test__/emitPublishWorkflow.test.ts b/generators/cli/src/__test__/emitPublishWorkflow.test.ts new file mode 100644 index 000000000000..a0b3cbe3811e --- /dev/null +++ b/generators/cli/src/__test__/emitPublishWorkflow.test.ts @@ -0,0 +1,124 @@ +import { mkdir, mkdtemp, readFile, rm } from "fs/promises"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { emitPublishWorkflow } from "../emitPublishWorkflow.js"; +import type { ResolvedNpmPublishInfo } from "../resolveOutputConfig.js"; + +/** + * Direct unit tests for `emitPublishWorkflow`. Validates the emitted + * `.github/workflows/ci.yml` content for token handling, OIDC + * permissions, and correct interpolation of binary/package names. + */ +describe("emitPublishWorkflow", () => { + let tmpDir: string; + let outputDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "emitPublishWorkflow-")); + outputDir = path.join(tmpDir, "out"); + await mkdir(outputDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + async function emitAndRead(npmPublishInfo: ResolvedNpmPublishInfo, binaryName = "acme"): Promise { + await emitPublishWorkflow({ outputDir, binaryName, npmPublishInfo }); + return readFile(path.join(outputDir, ".github", "workflows", "ci.yml"), "utf-8"); + } + + const baseInfo: ResolvedNpmPublishInfo = { + packageName: "@acme/cli", + registryUrl: "https://registry.npmjs.org", + tokenEnvironmentVariable: "NPM_TOKEN", + useOidc: false + }; + + // ── Standard token-based publishing ──────────────────────────── + + it("emits NODE_AUTH_TOKEN referencing the configured secret", async () => { + const yaml = await emitAndRead(baseInfo); + + expect(yaml).toContain("NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}"); + expect(yaml).not.toContain("id-token: write"); + }); + + it("uses a custom token variable name in the secret reference", async () => { + const yaml = await emitAndRead({ + ...baseInfo, + tokenEnvironmentVariable: "CUSTOM_REGISTRY_TOKEN" + }); + + expect(yaml).toContain("NODE_AUTH_TOKEN: ${{ secrets.CUSTOM_REGISTRY_TOKEN }}"); + }); + + // ── OIDC-based publishing ────────────────────────────────────── + + it("OIDC mode omits NODE_AUTH_TOKEN and adds id-token permissions", async () => { + const yaml = await emitAndRead({ + ...baseInfo, + tokenEnvironmentVariable: "", + useOidc: true + }); + + expect(yaml).not.toContain("NODE_AUTH_TOKEN"); + expect(yaml).not.toContain("secrets."); + expect(yaml).toContain("id-token: write"); + expect(yaml).toContain("contents: read"); + }); + + // ── Empty token fallback (the bug from item 1) ───────────────── + + it("does not produce an empty secrets reference (guards the empty-token bug)", async () => { + // In local mode the token can resolve to "" before reaching the + // generator. resolveNpmPublishInfo now normalises this to + // "NPM_TOKEN", but even if the caller passed "" directly the + // workflow template must never contain `secrets. }}` + const yaml = await emitAndRead({ + ...baseInfo, + tokenEnvironmentVariable: "NPM_TOKEN", + useOidc: false + }); + + // Should NOT match the broken pattern `secrets. }}` + expect(yaml).not.toMatch(/secrets\.\s*\}\}/); + }); + + // ── Structural assertions ────────────────────────────────────── + + it("contains the expected CI jobs (check, compile, test, publish, publish-launcher)", async () => { + const yaml = await emitAndRead(baseInfo); + + expect(yaml).toContain("check:"); + expect(yaml).toContain("compile:"); + expect(yaml).toContain("test:"); + expect(yaml).toContain("publish:"); + expect(yaml).toContain("publish-launcher:"); + }); + + it("interpolates the binary name and package name into the workflow", async () => { + const yaml = await emitAndRead(baseInfo, "my-tool"); + + expect(yaml).toContain('BINARY_NAME="my-tool"'); + expect(yaml).toContain("@acme/cli"); + expect(yaml).toContain("x86_64-unknown-linux-gnu"); + expect(yaml).toContain("aarch64-apple-darwin"); + }); + + it("uses the configured registry URL in setup-node", async () => { + const yaml = await emitAndRead({ + ...baseInfo, + registryUrl: "https://npm.pkg.github.com" + }); + + expect(yaml).toContain('registry-url: "https://npm.pkg.github.com"'); + }); + + it("tag-based publishing only triggers on tag pushes", async () => { + const yaml = await emitAndRead(baseInfo); + + expect(yaml).toContain("contains(github.ref, 'refs/tags/')"); + }); +}); diff --git a/generators/cli/src/__test__/patchCargoToml.test.ts b/generators/cli/src/__test__/patchCargoToml.test.ts index beb45e05d917..c2df710badf9 100644 --- a/generators/cli/src/__test__/patchCargoToml.test.ts +++ b/generators/cli/src/__test__/patchCargoToml.test.ts @@ -3,7 +3,7 @@ import os from "os"; import path from "path"; import url from "url"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { applyCargoTomlPatch, patchCargoToml } from "../index.js"; +import { applyCargoTomlPatch, patchCargoLockVersion, patchCargoToml } from "../index.js"; /** * Test against the real SDK template's `Cargo.toml`, not a hand-authored @@ -11,16 +11,20 @@ import { applyCargoTomlPatch, patchCargoToml } from "../index.js"; * these tests fail loudly — exactly when the patcher would silently * stop working in production. */ -const SDK_CARGO_TOML_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "../../sdk/Cargo.toml"); +const SDK_DIR = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "../../sdk"); +const SDK_CARGO_TOML_PATH = path.join(SDK_DIR, "Cargo.toml"); +const SDK_CARGO_LOCK_PATH = path.join(SDK_DIR, "Cargo.lock"); let TEMPLATE_CARGO_TOML: string; +let TEMPLATE_CARGO_LOCK: string; beforeAll(async () => { TEMPLATE_CARGO_TOML = await readFile(SDK_CARGO_TOML_PATH, "utf-8"); + TEMPLATE_CARGO_LOCK = await readFile(SDK_CARGO_LOCK_PATH, "utf-8"); }); describe("applyCargoTomlPatch", () => { it("rewrites the openapi-fixture [[bin]] name + path to the derived binary name", () => { - const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli"); + const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli", "1.2.3"); expect(patched).toContain('name = "acme-cli"'); expect(patched).toContain('path = "cli/acme-cli/main.rs"'); expect(patched).not.toContain('name = "openapi-fixture"'); @@ -28,17 +32,17 @@ describe("applyCargoTomlPatch", () => { }); it("leaves the [package] name (fern-cli-sdk) untouched — Cargo.lock pins it and --locked would reject a rename", () => { - const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli"); + const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli", "1.2.3"); expect(patched).toContain('name = "fern-cli-sdk"'); }); it("leaves the [lib] name (fern_cli_sdk, snake_case) untouched — every src/ import depends on it", () => { - const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli"); + const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli", "1.2.3"); expect(patched).toContain('name = "fern_cli_sdk"'); }); it("strips the strip-schema [[bin]] block — Fern-internal CI helper, paired with src/bin/strip_schema.rs in SDK_IGNORE", () => { - const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli"); + const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli", "1.2.3"); expect(patched).not.toContain('name = "strip-schema"'); expect(patched).not.toContain('path = "src/bin/strip_schema.rs"'); }); @@ -46,31 +50,36 @@ describe("applyCargoTomlPatch", () => { it("strips the template-author comment about Fern's package metadata", () => { // The comment block at the top of the file is meant for SDK // template authors, not customers. - const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli"); + const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli", "1.2.3"); expect(patched).not.toContain("The fern-cli generator does NOT rewrite this block"); expect(patched).not.toContain("identify the SDK template's source on crates.io"); }); it("strips the template-author comment above the [[bin]] block", () => { - const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli"); + const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli", "1.2.3"); expect(patched).not.toContain("Rewritten by the fern-cli generator's `patchCargoToml` step"); }); it('drops `readme = "README.md"` — no README ships in user output and the missing file breaks cargo package', () => { - const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli"); + const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli", "1.2.3"); expect(patched).not.toContain('readme = "README.md"'); }); it("flips [package.metadata.dist] dist = false to true so cargo-dist will package the user's CLI", () => { - const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli"); + const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli", "1.2.3"); expect(patched).not.toContain("dist = false"); expect(patched).toContain(`[package.metadata.dist] dist = true`); }); + it("stamps the resolved version into [package] version", () => { + const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli", "1.2.3"); + expect(patched).toContain('version = "1.2.3"'); + expect(patched).not.toContain('version = "0.18.1"'); + }); + it("preserves dependency versions, the [features] block, and [profile.dist]", () => { - const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli"); - expect(patched).toContain('version = "0.18.1"'); + const patched = applyCargoTomlPatch(TEMPLATE_CARGO_TOML, "acme-cli", "1.2.3"); expect(patched).toContain('repository = "https://github.com/fern-api/cli-sdk"'); expect(patched).toContain('anyhow = "1"'); expect(patched).toContain('default = ["native-tls"]'); @@ -78,12 +87,24 @@ dist = true`); }); it("throws with a clear pointer when an anchor is missing — guards against silent template drift", () => { - expect(() => applyCargoTomlPatch('[package]\nname = "unrelated"\n', "acme-cli")).toThrow( + expect(() => applyCargoTomlPatch('[package]\nname = "unrelated"\n', "acme-cli", "1.0.0")).toThrow( /patchCargoToml anchor missing/ ); }); }); +describe("patchCargoLockVersion", () => { + it("replaces the fern-cli-sdk version in Cargo.lock", () => { + const patched = patchCargoLockVersion(TEMPLATE_CARGO_LOCK, "3.0.0"); + expect(patched).toContain('name = "fern-cli-sdk"\nversion = "3.0.0"'); + expect(patched).not.toContain('name = "fern-cli-sdk"\nversion = "0.18.1"'); + }); + + it("throws when fern-cli-sdk entry is missing", () => { + expect(() => patchCargoLockVersion("version = 4\n", "1.0.0")).toThrow(/could not find fern-cli-sdk/); + }); +}); + describe("patchCargoToml (filesystem)", () => { let tmpDir: string; @@ -95,10 +116,11 @@ describe("patchCargoToml (filesystem)", () => { await rm(tmpDir, { recursive: true, force: true }); }); - it("reads, patches, and writes Cargo.toml in the output dir", async () => { + it("reads, patches, and writes Cargo.toml and Cargo.lock in the output dir", async () => { await writeFile(path.join(tmpDir, "Cargo.toml"), TEMPLATE_CARGO_TOML); + await writeFile(path.join(tmpDir, "Cargo.lock"), TEMPLATE_CARGO_LOCK); - await patchCargoToml({ outputDir: tmpDir, binaryName: "acme-cli" }); + await patchCargoToml({ outputDir: tmpDir, binaryName: "acme-cli", version: "2.0.0" }); const result = await readFile(path.join(tmpDir, "Cargo.toml"), "utf-8"); expect(result).toContain('name = "acme-cli"'); @@ -106,12 +128,17 @@ describe("patchCargoToml (filesystem)", () => { expect(result).toContain("dist = true"); expect(result).not.toContain('readme = "README.md"'); expect(result).not.toContain('name = "strip-schema"'); + + const lockResult = await readFile(path.join(tmpDir, "Cargo.lock"), "utf-8"); + expect(lockResult).toContain('name = "fern-cli-sdk"\nversion = "2.0.0"'); + expect(lockResult).not.toContain('name = "fern-cli-sdk"\nversion = "0.18.1"'); }); it("throws when none of the template anchors are present", async () => { await writeFile(path.join(tmpDir, "Cargo.toml"), '[package]\nname = "unrelated"\n'); + await writeFile(path.join(tmpDir, "Cargo.lock"), TEMPLATE_CARGO_LOCK); - await expect(patchCargoToml({ outputDir: tmpDir, binaryName: "acme-cli" })).rejects.toThrow( + await expect(patchCargoToml({ outputDir: tmpDir, binaryName: "acme-cli", version: "1.0.0" })).rejects.toThrow( /anchor missing|did not match/ ); }); diff --git a/generators/cli/src/__test__/resolveOutputConfig.test.ts b/generators/cli/src/__test__/resolveOutputConfig.test.ts new file mode 100644 index 000000000000..00bbecf8b0e8 --- /dev/null +++ b/generators/cli/src/__test__/resolveOutputConfig.test.ts @@ -0,0 +1,143 @@ +import { FernGeneratorExec } from "@fern-api/base-generator"; +import { describe, expect, it } from "vitest"; +import { type ResolvedNpmPublishInfo, resolveOutputConfig } from "../resolveOutputConfig.js"; + +/** + * Direct unit tests for `resolveOutputConfig`. Exercises every output + * mode variant and the npm-publish-info edge cases (empty token, + * OIDC sentinel, shouldGeneratePublishWorkflow: false). + * + * Uses the `FernGeneratorExec` SDK constructors (`OutputMode.github`, + * `GithubPublishInfo.npm`, `EnvironmentVariable`) so that test inputs + * carry the `_visit` methods the union types require. + */ +describe("resolveOutputConfig", () => { + const env = FernGeneratorExec.EnvironmentVariable; + + function githubOutput(args: { + version: string; + publishInfo?: FernGeneratorExec.GithubPublishInfo; + }): FernGeneratorExec.GeneratorOutputConfig { + return { + path: "/out", + mode: FernGeneratorExec.OutputMode.github({ + version: args.version, + repoUrl: "https://github.com/acme/cli", + publishInfo: args.publishInfo + }) + }; + } + + // ── output mode: github ──────────────────────────────────────── + + it("github mode with npm publishInfo returns version + npmPublishInfo", () => { + const result = resolveOutputConfig( + githubOutput({ + version: "2.0.0", + publishInfo: FernGeneratorExec.GithubPublishInfo.npm({ + packageName: "@acme/cli", + registryUrl: "https://registry.npmjs.org", + tokenEnvironmentVariable: env("MY_NPM_TOKEN"), + shouldGeneratePublishWorkflow: true + }) + }) + ); + + expect(result.version).toBe("2.0.0"); + expect(result.npmPublishInfo).toEqual({ + packageName: "@acme/cli", + registryUrl: "https://registry.npmjs.org", + tokenEnvironmentVariable: "MY_NPM_TOKEN", + useOidc: false + }); + }); + + it("github mode without publishInfo returns version + no npmPublishInfo", () => { + const result = resolveOutputConfig(githubOutput({ version: "1.0.0" })); + + expect(result.version).toBe("1.0.0"); + expect(result.npmPublishInfo).toBeUndefined(); + }); + + // ── output mode: downloadFiles ───────────────────────────────── + + it("downloadFiles mode returns default version with no npmPublishInfo", () => { + const result = resolveOutputConfig({ + path: "/out", + mode: FernGeneratorExec.OutputMode.downloadFiles() + }); + + expect(result.version).toBe("0.0.0"); + expect(result.npmPublishInfo).toBeUndefined(); + }); + + // ── npm publish info edge cases ──────────────────────────────── + + it("empty tokenEnvironmentVariable falls back to NPM_TOKEN", () => { + const result = resolveOutputConfig( + githubOutput({ + version: "1.0.0", + publishInfo: FernGeneratorExec.GithubPublishInfo.npm({ + packageName: "@acme/cli", + registryUrl: "https://registry.npmjs.org", + tokenEnvironmentVariable: env(""), + shouldGeneratePublishWorkflow: true + }) + }) + ); + + expect(result.npmPublishInfo).toBeDefined(); + expect(result.npmPublishInfo?.tokenEnvironmentVariable).toBe("NPM_TOKEN"); + expect(result.npmPublishInfo?.useOidc).toBe(false); + }); + + it(" sentinel sets useOidc = true and preserves the sentinel value", () => { + const result = resolveOutputConfig( + githubOutput({ + version: "1.0.0", + publishInfo: FernGeneratorExec.GithubPublishInfo.npm({ + packageName: "@acme/cli", + registryUrl: "https://registry.npmjs.org", + tokenEnvironmentVariable: env(""), + shouldGeneratePublishWorkflow: true + }) + }) + ); + + expect(result.npmPublishInfo).toBeDefined(); + expect(result.npmPublishInfo?.tokenEnvironmentVariable).toBe(""); + expect(result.npmPublishInfo?.useOidc).toBe(true); + }); + + it("shouldGeneratePublishWorkflow: false suppresses npm publish info", () => { + const result = resolveOutputConfig( + githubOutput({ + version: "1.0.0", + publishInfo: FernGeneratorExec.GithubPublishInfo.npm({ + packageName: "@acme/cli", + registryUrl: "https://registry.npmjs.org", + tokenEnvironmentVariable: env("NPM_TOKEN"), + shouldGeneratePublishWorkflow: false + }) + }) + ); + + expect(result.npmPublishInfo).toBeUndefined(); + }); + + it("non-npm publish type (maven) is ignored by the CLI generator", () => { + const result = resolveOutputConfig( + githubOutput({ + version: "1.0.0", + publishInfo: FernGeneratorExec.GithubPublishInfo.maven({ + registryUrl: "https://maven.example.com", + coordinate: "com.acme:cli", + usernameEnvironmentVariable: env("MAVEN_USER"), + passwordEnvironmentVariable: env("MAVEN_PASS") + }) + }) + ); + + expect(result.npmPublishInfo).toBeUndefined(); + }); +}); diff --git a/generators/cli/src/__test__/runPipeline.test.ts b/generators/cli/src/__test__/runPipeline.test.ts index c23c248acc30..b639e4e43a20 100644 --- a/generators/cli/src/__test__/runPipeline.test.ts +++ b/generators/cli/src/__test__/runPipeline.test.ts @@ -4,6 +4,7 @@ import os from "os"; import path from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { IrSummary } from "../ir.js"; +import type { ResolvedOutputConfig } from "../resolveOutputConfig.js"; import { runPipeline } from "../runPipeline.js"; /** @@ -66,6 +67,19 @@ describe("runPipeline", () => { "" ].join("\n") ); + await writeFile( + path.join(sdkTemplateDir, "Cargo.lock"), + [ + "# This file is automatically @generated by Cargo.", + "# It is not intended for manual editing.", + "version = 4", + "", + "[[package]]", + 'name = "fern-cli-sdk"', + 'version = "0.0.0"', + "" + ].join("\n") + ); await writeFile(path.join(sdkTemplateDir, "LICENSE"), "Apache-2.0"); await mkdir(path.join(sdkTemplateDir, "src"), { recursive: true }); await writeFile(path.join(sdkTemplateDir, "src", "lib.rs"), "// SDK lib"); @@ -91,6 +105,21 @@ describe("runPipeline", () => { auth: overrides.auth ?? { schemes: [] } }); + const localFilesConfig: ResolvedOutputConfig = { + version: "0.0.0", + npmPublishInfo: undefined + }; + + const githubConfig: ResolvedOutputConfig = { + version: "1.5.0", + npmPublishInfo: { + packageName: "@acme/cli", + registryUrl: "https://registry.npmjs.org", + tokenEnvironmentVariable: "NPM_TOKEN", + useOidc: false + } + }; + it("returns skipped when no OpenAPI specs are mounted; never touches the output dir", async () => { await stageSdkTemplate(); @@ -99,6 +128,7 @@ describe("runPipeline", () => { outputDir, customConfig: {}, ir: ir(), + outputConfig: localFilesConfig, sdkTemplateDir, specsDir }); @@ -117,6 +147,7 @@ describe("runPipeline", () => { outputDir, customConfig: {}, ir: ir({ apiDisplayName: "My API" }), + outputConfig: localFilesConfig, sdkTemplateDir, specsDir }); @@ -150,6 +181,7 @@ describe("runPipeline", () => { outputDir, customConfig: { binaryName: "Override CLI" }, ir: ir({ apiDisplayName: "Should Not Win" }), + outputConfig: localFilesConfig, sdkTemplateDir, specsDir }); @@ -191,6 +223,7 @@ describe("runPipeline", () => { ] } }), + outputConfig: localFilesConfig, sdkTemplateDir, specsDir }); @@ -208,11 +241,78 @@ describe("runPipeline", () => { await stageSdkTemplate(); await stageSpecs([{ filename: "openapi0.json", body: { openapi: "3.0.0" } }]); - await expect(runPipeline({ outputDir, customConfig: {}, ir: ir(), sdkTemplateDir, specsDir })).rejects.toThrow( - /Set `customConfig.binaryName`/ - ); + await expect( + runPipeline({ + outputDir, + customConfig: {}, + ir: ir(), + outputConfig: localFilesConfig, + sdkTemplateDir, + specsDir + }) + ).rejects.toThrow(/Set `customConfig.binaryName`/); // The error came BEFORE any output got created. await expect(access(outputDir)).rejects.toThrow(); }); + + it("stamps the resolved version into Cargo.toml [package] version", async () => { + await stageSdkTemplate(); + await stageSpecs([{ filename: "openapi0.json", body: { openapi: "3.0.0" } }]); + + const versionedConfig: ResolvedOutputConfig = { version: "2.3.1", npmPublishInfo: undefined }; + await runPipeline({ + outputDir, + customConfig: { binaryName: "acme" }, + ir: ir({ apiDisplayName: "Acme" }), + outputConfig: versionedConfig, + sdkTemplateDir, + specsDir + }); + + const cargo = await readFile(path.join(outputDir, "Cargo.toml"), "utf-8"); + expect(cargo).toContain('version = "2.3.1"'); + + const lock = await readFile(path.join(outputDir, "Cargo.lock"), "utf-8"); + expect(lock).toContain('name = "fern-cli-sdk"\nversion = "2.3.1"'); + }); + + it("does NOT emit .github/ when output mode is local_files (no npmPublishInfo)", async () => { + await stageSdkTemplate(); + await stageSpecs([{ filename: "openapi0.json", body: { openapi: "3.0.0" } }]); + + await runPipeline({ + outputDir, + customConfig: { binaryName: "acme" }, + ir: ir({ apiDisplayName: "Acme" }), + outputConfig: localFilesConfig, + sdkTemplateDir, + specsDir + }); + + await expect(access(path.join(outputDir, ".github"))).rejects.toThrow(); + }); + + it("emits .github/workflows/ci.yml when github mode with npm publish info", async () => { + await stageSdkTemplate(); + await stageSpecs([{ filename: "openapi0.json", body: { openapi: "3.0.0" } }]); + + await runPipeline({ + outputDir, + customConfig: { binaryName: "acme" }, + ir: ir({ apiDisplayName: "Acme" }), + outputConfig: githubConfig, + sdkTemplateDir, + specsDir + }); + + const ciYml = await readFile(path.join(outputDir, ".github", "workflows", "ci.yml"), "utf-8"); + expect(ciYml).toContain("name: ci"); + expect(ciYml).toContain("contains(github.ref, 'refs/tags/')"); + expect(ciYml).toContain("NPM_TOKEN"); + expect(ciYml).toContain("@acme/cli"); + expect(ciYml).toContain("npm publish"); + expect(ciYml).toContain("x86_64-unknown-linux-gnu"); + expect(ciYml).toContain("aarch64-apple-darwin"); + }); }); diff --git a/generators/cli/src/cli.ts b/generators/cli/src/cli.ts index e8640c2f4684..249d078c08b6 100644 --- a/generators/cli/src/cli.ts +++ b/generators/cli/src/cli.ts @@ -10,6 +10,7 @@ import { } from "@fern-api/base-generator"; import { getCustomConfig } from "./customConfig.js"; import { readIrSummary } from "./ir.js"; +import { resolveOutputConfig } from "./resolveOutputConfig.js"; import { runPipeline } from "./runPipeline.js"; const pathToConfig = process.argv[process.argv.length - 1]; @@ -45,10 +46,12 @@ async function generate(configPath: string): Promise { ); const ir = await readIrSummary(config.irFilepath); + const outputConfig = resolveOutputConfig(config.output); const outcome = await runPipeline({ outputDir: config.output.path, customConfig: getCustomConfig(config), - ir + ir, + outputConfig }); if (outcome.status === "skipped") { diff --git a/generators/cli/src/emitPublishWorkflow.ts b/generators/cli/src/emitPublishWorkflow.ts new file mode 100644 index 000000000000..833e4f8b6d84 --- /dev/null +++ b/generators/cli/src/emitPublishWorkflow.ts @@ -0,0 +1,299 @@ +import { mkdir, writeFile } from "fs/promises"; +import path from "path"; +import type { ResolvedNpmPublishInfo } from "./resolveOutputConfig.js"; + +/** + * Cross-compilation targets for the CLI binary. Each entry maps a + * Rust target triple to the npm platform package suffix that cargo-dist + * / the npm embedded-binary convention expects. + */ +const TARGETS: ReadonlyArray<{ + rustTarget: string; + runner: string; + npmPlatformSuffix: string; +}> = [ + { rustTarget: "x86_64-unknown-linux-gnu", runner: "ubuntu-latest", npmPlatformSuffix: "linux-x64" }, + { rustTarget: "aarch64-unknown-linux-gnu", runner: "ubuntu-latest", npmPlatformSuffix: "linux-arm64" }, + { rustTarget: "x86_64-apple-darwin", runner: "macos-latest", npmPlatformSuffix: "darwin-x64" }, + { rustTarget: "aarch64-apple-darwin", runner: "macos-latest", npmPlatformSuffix: "darwin-arm64" }, + { rustTarget: "x86_64-pc-windows-msvc", runner: "windows-latest", npmPlatformSuffix: "win32-x64" } +]; + +/** + * Emit `.github/workflows/ci.yml` into the generated CLI output. + * + * The workflow: + * - **check / compile / test** jobs run on every push (mirror the + * Rust SDK's emitted `ci.yml`). + * - **publish** job runs only on tag pushes, builds cross-platform + * binaries, packages each as an embedded-binary npm platform + * package, assembles a thin launcher package, and publishes all + * to npm via `secrets.NPM_TOKEN`. + * + * The embedded-binary packaging model (the esbuild/swc pattern) means + * the binary bytes live *inside* the npm tarball. This decouples + * artifact distribution from GitHub source-repo visibility — a + * customer can keep their CLI source private and still have a freely + * `npm i -g` / `npx`-installable CLI. + */ +export async function emitPublishWorkflow(args: { + outputDir: string; + binaryName: string; + npmPublishInfo: ResolvedNpmPublishInfo; +}): Promise { + const { outputDir, binaryName, npmPublishInfo } = args; + const workflowsDir = path.join(outputDir, ".github", "workflows"); + await mkdir(workflowsDir, { recursive: true }); + const yaml = constructWorkflowYaml({ binaryName, npmPublishInfo }); + await writeFile(path.join(workflowsDir, "ci.yml"), yaml); +} + +function constructWorkflowYaml(args: { binaryName: string; npmPublishInfo: ResolvedNpmPublishInfo }): string { + const { binaryName, npmPublishInfo } = args; + const { useOidc } = npmPublishInfo; + const tokenVar = npmPublishInfo.tokenEnvironmentVariable; + + const oidcPermissions = useOidc ? `\n permissions:\n contents: read\n id-token: write` : ""; + const tokenEnvBlock = useOidc ? "" : `\n env:\n NODE_AUTH_TOKEN: \${{ secrets.${tokenVar} }}`; + + const matrixIncludes = TARGETS.map( + (t) => + ` - rust-target: ${t.rustTarget}\n` + + ` runner: ${t.runner}\n` + + ` npm-platform-suffix: ${t.npmPlatformSuffix}` + ).join("\n"); + + // Bash lines that build up the launcher's optionalDependencies map. + // Each entry ends with a trailing comma except the last — JSON + // forbids trailing commas inside `{ ... }`. + const optionalDepsLines = TARGETS.map((t, i) => { + const trailingComma = i === TARGETS.length - 1 ? "" : ","; + return ` OPTIONAL_DEPS="\${OPTIONAL_DEPS}\\"${npmPublishInfo.packageName}-${t.npmPlatformSuffix}\\": \\"\${VERSION}\\"${trailingComma}"`; + }).join("\n"); + + // JavaScript object-literal entries for the launcher's PLATFORMS + // map. Trailing commas are legal in ES5+ object literals so every + // entry gets one — keeps diffs small when targets are added. + const launcherPlatformEntries = TARGETS.map( + (t) => ` "${t.npmPlatformSuffix}": "${npmPublishInfo.packageName}-${t.npmPlatformSuffix}",` + ).join("\n"); + + return `name: ci + +on: [push] + +concurrency: + group: \${{ github.workflow }}-\${{ github.ref }} + cancel-in-progress: false + +env: + RUSTFLAGS: "-A warnings" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Check + run: cargo check + + compile: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Compile + run: cargo build + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Test + run: cargo test + + publish: + needs: [check, compile, test] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: \${{ matrix.runner }}${oidcPermissions} + strategy: + # Don't cancel sibling matrix jobs on first failure — a transient + # failure on one platform would otherwise leave npm in a partial + # state (some platform packages published, others not, launcher + # never published), with no clean re-run since the already- + # published versions reject re-publish. + fail-fast: false + matrix: + include: +${matrixIncludes} + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: \${{ matrix.rust-target }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + registry-url: "${npmPublishInfo.registryUrl}" + + - name: Install cross-compilation tools + if: matrix.rust-target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + + - name: Build release binary + run: cargo build --release --locked --target \${{ matrix.rust-target }} + + - name: Package and publish npm platform package${tokenEnvBlock} + shell: bash + run: | + set -euo pipefail + + VERSION="\${GITHUB_REF_NAME#v}" + PLATFORM_PKG="${npmPublishInfo.packageName}-\${{ matrix.npm-platform-suffix }}" + PKG_DIR="npm-pkg/\${PLATFORM_PKG}" + mkdir -p "\${PKG_DIR}" + + # Locate the compiled binary + BINARY_NAME="${binaryName}" + if [[ "\${{ matrix.rust-target }}" == *"windows"* ]]; then + BINARY_NAME="${binaryName}.exe" + fi + cp "target/\${{ matrix.rust-target }}/release/\${BINARY_NAME}" "\${PKG_DIR}/" + + # Write platform package.json + cat > "\${PKG_DIR}/package.json" < "\${PKG_DIR}/package.json" < "\${PKG_DIR}/bin/cli.js" <<'LAUNCHER' + #!/usr/bin/env node + "use strict"; + const { execFileSync } = require("child_process"); + const path = require("path"); + const os = require("os"); + + const PLATFORMS = { +${launcherPlatformEntries} + }; + + const platformKey = os.platform() + "-" + os.arch(); + const pkg = PLATFORMS[platformKey]; + if (!pkg) { + console.error("Unsupported platform: " + platformKey); + process.exit(1); + } + + const binName = os.platform() === "win32" ? "${binaryName}.exe" : "${binaryName}"; + const binPath = path.join(require.resolve(pkg + "/package.json"), "..", binName); + + try { + execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" }); + } catch (e) { + if (e && typeof e === "object" && "status" in e) { + process.exit(e.status); + } + throw e; + } + LAUNCHER + + cd "\${PKG_DIR}" + # Pre-release detection — require the semver "-" separator so a + # release tag like v1.0.0 for a package whose version string + # happens to contain "alpha"/"beta" as a substring isn't + # mis-tagged on npm. + if [[ "\${VERSION}" == *-alpha* ]]; then + npm publish --access public --tag alpha + elif [[ "\${VERSION}" == *-beta* ]]; then + npm publish --access public --tag beta + else + npm publish --access public + fi +`; +} diff --git a/generators/cli/src/index.ts b/generators/cli/src/index.ts index 2cfbfe1eb7cf..3cdcbff2de16 100644 --- a/generators/cli/src/index.ts +++ b/generators/cli/src/index.ts @@ -10,8 +10,14 @@ export { } from "./copySpecs.js"; export { type FernCliCustomConfig, getCustomConfig } from "./customConfig.js"; export { type DetectedAuthBinding, detectAuthBindings } from "./detectAuth.js"; +export { emitPublishWorkflow } from "./emitPublishWorkflow.js"; export { deriveBinaryName, TEMPLATE_BINARY_NAME, toEnvVarPrefix, toKebabCase } from "./identity.js"; export { type IrSummary, readIrSummary } from "./ir.js"; -export { applyCargoTomlPatch, patchCargoToml } from "./patchCargoToml.js"; +export { applyCargoTomlPatch, patchCargoLockVersion, patchCargoToml } from "./patchCargoToml.js"; export { applyDistWorkspacePatch, patchDistWorkspaceToml } from "./patchDistWorkspace.js"; +export { + type ResolvedNpmPublishInfo, + type ResolvedOutputConfig, + resolveOutputConfig +} from "./resolveOutputConfig.js"; export { type PipelineOutcome, runPipeline } from "./runPipeline.js"; diff --git a/generators/cli/src/patchCargoToml.ts b/generators/cli/src/patchCargoToml.ts index 25874dc321ce..db8f60bdcaa4 100644 --- a/generators/cli/src/patchCargoToml.ts +++ b/generators/cli/src/patchCargoToml.ts @@ -30,11 +30,11 @@ import { TEMPLATE_BINARY_NAME } from "./identity.js"; * in the shipped src/ tree depends on it. * - All dependency versions, features, and the `[profile.dist]` block. */ -export async function patchCargoToml(args: { outputDir: string; binaryName: string }): Promise { - const { outputDir, binaryName } = args; +export async function patchCargoToml(args: { outputDir: string; binaryName: string; version: string }): Promise { + const { outputDir, binaryName, version } = args; const cargoTomlPath = path.join(outputDir, "Cargo.toml"); const contents = await readFile(cargoTomlPath, "utf-8"); - const patched = applyCargoTomlPatch(contents, binaryName); + const patched = applyCargoTomlPatch(contents, binaryName, version); if (patched === contents) { throw new Error( `Cargo.toml at ${cargoTomlPath} did not match the expected template — no substitutions made. ` + @@ -42,6 +42,15 @@ export async function patchCargoToml(args: { outputDir: string; binaryName: stri ); } await writeFile(cargoTomlPath, patched); + + // Cargo.lock records the package's own version alongside its + // dependencies. When we stamp a resolved version into Cargo.toml, + // the lockfile entry must match — otherwise `cargo build --locked` + // rejects the build. + const cargoLockPath = path.join(outputDir, "Cargo.lock"); + const lockContents = await readFile(cargoLockPath, "utf-8"); + const patchedLock = patchCargoLockVersion(lockContents, version); + await writeFile(cargoLockPath, patchedLock); } /** @@ -50,7 +59,7 @@ export async function patchCargoToml(args: { outputDir: string; binaryName: stri * patcher's expectations becomes a test failure rather than a silent * skip. */ -export function applyCargoTomlPatch(cargoToml: string, binaryName: string): string { +export function applyCargoTomlPatch(cargoToml: string, binaryName: string, version: string): string { let patched = cargoToml; patched = requireReplace(patched, TEMPLATE_TOP_COMMENT, ""); patched = requireReplace(patched, TEMPLATE_BIN_COMMENT, ""); @@ -63,9 +72,43 @@ export function applyCargoTomlPatch(cargoToml: string, binaryName: string): stri `path = "cli/${TEMPLATE_BINARY_NAME}/main.rs"`, `path = "cli/${binaryName}/main.rs"` ); + patched = replaceVersion(patched, version); return patched; } +/** + * Replace the template's `version = "..."` field under `[package]` + * with the resolved version. Uses a regex anchored to the first + * occurrence so it won't accidentally match version fields inside + * `[dependencies]`. + */ +function replaceVersion(cargoToml: string, version: string): string { + const versionRe = /^(version\s*=\s*)"[^"]*"/m; + if (!versionRe.test(cargoToml)) { + throw new Error('patchCargoToml anchor missing — could not find version = "..." field'); + } + return cargoToml.replace(versionRe, `$1"${version}"`); +} + +/** + * Patch the `version` field of the `fern-cli-sdk` package entry in + * Cargo.lock to match the version stamped into Cargo.toml. Cargo.lock + * records each package as: + * + * [[package]] + * name = "fern-cli-sdk" + * version = "0.18.1" + * + * We locate the `fern-cli-sdk` entry and replace its version. + */ +export function patchCargoLockVersion(cargoLock: string, version: string): string { + const pattern = /(name = "fern-cli-sdk"\nversion = ")([^"]*)(")/; + if (!pattern.test(cargoLock)) { + throw new Error("patchCargoToml: could not find fern-cli-sdk version entry in Cargo.lock"); + } + return cargoLock.replace(pattern, `$1${version}$3`); +} + function requireReplace(haystack: string, needle: string, replacement: string): string { if (!haystack.includes(needle)) { throw new Error(`patchCargoToml anchor missing — could not find ${JSON.stringify(needle.slice(0, 60))}`); diff --git a/generators/cli/src/resolveOutputConfig.ts b/generators/cli/src/resolveOutputConfig.ts new file mode 100644 index 000000000000..1f50428bb779 --- /dev/null +++ b/generators/cli/src/resolveOutputConfig.ts @@ -0,0 +1,106 @@ +import type { GeneratorConfig } from "@fern-api/base-generator"; +import { assertNeverNoThrow } from "@fern-api/core-utils"; + +/** + * Resolved output configuration the CLI pipeline consumes. + * + * `version` follows the Rust SDK's `getCrateVersion()` precedence: + * 1. `customConfig` version override (future — not wired yet) + * 2. `config.output.mode` version (github / publish modes) + * 3. default "0.0.0" + * + * `npmPublishInfo` is present only in github output mode when the + * upstream config includes npm publish metadata. The pipeline uses it + * to gate workflow emission and stamp the npm package name. + */ +export interface ResolvedOutputConfig { + version: string; + /** + * Non-null only when output mode is `github` and the upstream + * config includes `publishInfo` of type `npm`. The pipeline + * emits `.github/workflows/ci.yml` only when this is set. + */ + npmPublishInfo: ResolvedNpmPublishInfo | undefined; +} + +export interface ResolvedNpmPublishInfo { + packageName: string; + registryUrl: string; + /** + * The name of the GitHub Actions secret holding the npm auth token. + * Set to `""` when OIDC-based publishing is configured. + */ + tokenEnvironmentVariable: string; + useOidc: boolean; +} + +const DEFAULT_VERSION = "0.0.0"; + +type OutputMode = GeneratorConfig["output"]["mode"]; + +/** + * Visit `config.output.mode` to extract version + npm publish info. + * + * Mirrors the Rust SDK's `getCrateVersion()` pattern: the resolved + * version comes from the mode union (`github.version`, + * `publish.version`), falling back to a default when no version is + * supplied (e.g. `downloadFiles` / `local_files` mode). + */ +export function resolveOutputConfig(output: GeneratorConfig["output"]): ResolvedOutputConfig { + const mode: OutputMode = output.mode; + switch (mode.type) { + case "github": { + return { + version: mode.version, + npmPublishInfo: resolveNpmPublishInfo(mode.publishInfo) + }; + } + case "publish": + return { + version: mode.version, + npmPublishInfo: undefined + }; + case "downloadFiles": + return { + version: DEFAULT_VERSION, + npmPublishInfo: undefined + }; + default: + // Forward-compatible: unknown output modes fall back to default version with no npm publishing. + assertNeverNoThrow(mode as never); + return { + version: DEFAULT_VERSION, + npmPublishInfo: undefined + }; + } +} + +type GithubPublishInfo = NonNullable["publishInfo"]>; + +function resolveNpmPublishInfo(publishInfo: GithubPublishInfo | undefined): ResolvedNpmPublishInfo | undefined { + if (publishInfo == null) { + return undefined; + } + switch (publishInfo.type) { + case "npm": { + if (publishInfo.shouldGeneratePublishWorkflow === false) { + return undefined; + } + const useOidc = publishInfo.tokenEnvironmentVariable === ""; + const tokenEnvironmentVariable = + useOidc || (publishInfo.tokenEnvironmentVariable != null && publishInfo.tokenEnvironmentVariable !== "") + ? publishInfo.tokenEnvironmentVariable + : "NPM_TOKEN"; + return { + packageName: publishInfo.packageName, + registryUrl: publishInfo.registryUrl, + tokenEnvironmentVariable, + useOidc + }; + } + default: + // Non-npm publish types (maven, pypi, nuget, etc.) are intentionally unsupported by the CLI generator. + assertNeverNoThrow(publishInfo as never); + return undefined; + } +} diff --git a/generators/cli/src/runPipeline.ts b/generators/cli/src/runPipeline.ts index a5ff15107283..3514a59eb1b2 100644 --- a/generators/cli/src/runPipeline.ts +++ b/generators/cli/src/runPipeline.ts @@ -3,10 +3,12 @@ import { copySdk, SDK_TEMPLATE_DIRECTORY } from "./copySdk.js"; import { copySpecs, hasOpenApiSpecs } from "./copySpecs.js"; import type { FernCliCustomConfig } from "./customConfig.js"; import { detectAuthBindings } from "./detectAuth.js"; +import { emitPublishWorkflow } from "./emitPublishWorkflow.js"; import { deriveBinaryName } from "./identity.js"; import type { IrSummary } from "./ir.js"; import { patchCargoToml } from "./patchCargoToml.js"; import { patchDistWorkspaceToml } from "./patchDistWorkspace.js"; +import type { ResolvedOutputConfig } from "./resolveOutputConfig.js"; import { writeGitignore } from "./writeGitignore.js"; export type PipelineOutcome = @@ -31,10 +33,11 @@ export async function runPipeline(args: { outputDir: string; customConfig: FernCliCustomConfig; ir: IrSummary; + outputConfig: ResolvedOutputConfig; sdkTemplateDir?: string; specsDir?: string; }): Promise { - const { outputDir, customConfig, ir, sdkTemplateDir, specsDir } = args; + const { outputDir, customConfig, ir, outputConfig, sdkTemplateDir, specsDir } = args; if (!(await hasOpenApiSpecs(specsDir))) { return { status: "skipped", reason: "no-openapi-specs" }; @@ -57,11 +60,21 @@ export async function runPipeline(args: { // identifying bits don't leak Fern's template-author branding. // 3. copySpecs writes the spec files + main.rs into // `cli//`, wiring the IR-derived auth bindings. + // 4. emitPublishWorkflow writes `.github/workflows/ci.yml` when + // output mode is `github` with npm publish info. await copySdk(outputDir, sdkTemplateDir ?? SDK_TEMPLATE_DIRECTORY); - await patchCargoToml({ outputDir, binaryName }); + await patchCargoToml({ outputDir, binaryName, version: outputConfig.version }); await patchDistWorkspaceToml({ outputDir }); await copySpecs({ outputDir, binaryName, authBindings, specsDir }); await writeGitignore(outputDir); + if (outputConfig.npmPublishInfo != null) { + await emitPublishWorkflow({ + outputDir, + binaryName, + npmPublishInfo: outputConfig.npmPublishInfo + }); + } + return { status: "generated", binaryName }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d77720340582..edcd65b55174 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -792,6 +792,9 @@ importers: '@fern-api/configs': specifier: workspace:* version: link:../../packages/configs + '@fern-api/core-utils': + specifier: workspace:* + version: link:../../packages/commons/core-utils '@fern-api/fs-utils': specifier: workspace:* version: link:../../packages/commons/fs-utils diff --git a/seed/cli/allof-inline/Cargo.lock b/seed/cli/allof-inline/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/allof-inline/Cargo.lock +++ b/seed/cli/allof-inline/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/allof-inline/Cargo.toml b/seed/cli/allof-inline/Cargo.toml index 6b45c0e2e02a..cbbae800961c 100644 --- a/seed/cli/allof-inline/Cargo.toml +++ b/seed/cli/allof-inline/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/allof/Cargo.lock b/seed/cli/allof/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/allof/Cargo.lock +++ b/seed/cli/allof/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/allof/Cargo.toml b/seed/cli/allof/Cargo.toml index 6b45c0e2e02a..cbbae800961c 100644 --- a/seed/cli/allof/Cargo.toml +++ b/seed/cli/allof/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/api-wide-base-path-with-default/Cargo.lock b/seed/cli/api-wide-base-path-with-default/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/api-wide-base-path-with-default/Cargo.lock +++ b/seed/cli/api-wide-base-path-with-default/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/api-wide-base-path-with-default/Cargo.toml b/seed/cli/api-wide-base-path-with-default/Cargo.toml index 30ae02bd91eb..e7557975c1ed 100644 --- a/seed/cli/api-wide-base-path-with-default/Cargo.toml +++ b/seed/cli/api-wide-base-path-with-default/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.lock b/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.lock +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.toml b/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.toml index 2f722e02d681..c0cf9cf0c39a 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.toml +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/cli-multi-spec/no-custom-config/Cargo.lock b/seed/cli/cli-multi-spec/no-custom-config/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/Cargo.lock +++ b/seed/cli/cli-multi-spec/no-custom-config/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/cli-multi-spec/no-custom-config/Cargo.toml b/seed/cli/cli-multi-spec/no-custom-config/Cargo.toml index f8f095ee113f..d1719064dad1 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/Cargo.toml +++ b/seed/cli/cli-multi-spec/no-custom-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/file-upload-openapi/Cargo.lock b/seed/cli/file-upload-openapi/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/file-upload-openapi/Cargo.lock +++ b/seed/cli/file-upload-openapi/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/file-upload-openapi/Cargo.toml b/seed/cli/file-upload-openapi/Cargo.toml index eb9c7d79c33b..487f62498058 100644 --- a/seed/cli/file-upload-openapi/Cargo.toml +++ b/seed/cli/file-upload-openapi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/imdb/Cargo.lock b/seed/cli/imdb/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/imdb/Cargo.lock +++ b/seed/cli/imdb/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/imdb/Cargo.toml b/seed/cli/imdb/Cargo.toml index eb9c7d79c33b..487f62498058 100644 --- a/seed/cli/imdb/Cargo.toml +++ b/seed/cli/imdb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/multi-url-environment-reference/Cargo.lock b/seed/cli/multi-url-environment-reference/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/multi-url-environment-reference/Cargo.lock +++ b/seed/cli/multi-url-environment-reference/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/multi-url-environment-reference/Cargo.toml b/seed/cli/multi-url-environment-reference/Cargo.toml index 7a22418ee9fd..c061e24d86de 100644 --- a/seed/cli/multi-url-environment-reference/Cargo.toml +++ b/seed/cli/multi-url-environment-reference/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/no-content-response/Cargo.lock b/seed/cli/no-content-response/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/no-content-response/Cargo.lock +++ b/seed/cli/no-content-response/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/no-content-response/Cargo.toml b/seed/cli/no-content-response/Cargo.toml index ca4a5fcc3635..32553d4b8881 100644 --- a/seed/cli/no-content-response/Cargo.toml +++ b/seed/cli/no-content-response/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/null-type/Cargo.lock b/seed/cli/null-type/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/null-type/Cargo.lock +++ b/seed/cli/null-type/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/null-type/Cargo.toml b/seed/cli/null-type/Cargo.toml index 1519663ef199..7f0ad69c3caa 100644 --- a/seed/cli/null-type/Cargo.toml +++ b/seed/cli/null-type/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/nullable-allof-extends/Cargo.lock b/seed/cli/nullable-allof-extends/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/nullable-allof-extends/Cargo.lock +++ b/seed/cli/nullable-allof-extends/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/nullable-allof-extends/Cargo.toml b/seed/cli/nullable-allof-extends/Cargo.toml index 788af76b1b07..11f06cda8b89 100644 --- a/seed/cli/nullable-allof-extends/Cargo.toml +++ b/seed/cli/nullable-allof-extends/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/nullable-request-body/Cargo.lock b/seed/cli/nullable-request-body/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/nullable-request-body/Cargo.lock +++ b/seed/cli/nullable-request-body/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/nullable-request-body/Cargo.toml b/seed/cli/nullable-request-body/Cargo.toml index 0a453b5adcd1..a04839aebaf0 100644 --- a/seed/cli/nullable-request-body/Cargo.toml +++ b/seed/cli/nullable-request-body/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/oauth-client-credentials-openapi/Cargo.lock b/seed/cli/oauth-client-credentials-openapi/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/oauth-client-credentials-openapi/Cargo.lock +++ b/seed/cli/oauth-client-credentials-openapi/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/oauth-client-credentials-openapi/Cargo.toml b/seed/cli/oauth-client-credentials-openapi/Cargo.toml index 2970a2601486..a15dee5e707e 100644 --- a/seed/cli/oauth-client-credentials-openapi/Cargo.toml +++ b/seed/cli/oauth-client-credentials-openapi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/openapi-request-body-ref/Cargo.lock b/seed/cli/openapi-request-body-ref/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/openapi-request-body-ref/Cargo.lock +++ b/seed/cli/openapi-request-body-ref/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/openapi-request-body-ref/Cargo.toml b/seed/cli/openapi-request-body-ref/Cargo.toml index fb433c421e4b..3924dc6591e7 100644 --- a/seed/cli/openapi-request-body-ref/Cargo.toml +++ b/seed/cli/openapi-request-body-ref/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/query-param-name-conflict/Cargo.lock b/seed/cli/query-param-name-conflict/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/query-param-name-conflict/Cargo.lock +++ b/seed/cli/query-param-name-conflict/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/query-param-name-conflict/Cargo.toml b/seed/cli/query-param-name-conflict/Cargo.toml index 1901ceed7942..25da574bbbe8 100644 --- a/seed/cli/query-param-name-conflict/Cargo.toml +++ b/seed/cli/query-param-name-conflict/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/query-parameters-openapi-as-objects/Cargo.lock b/seed/cli/query-parameters-openapi-as-objects/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/query-parameters-openapi-as-objects/Cargo.lock +++ b/seed/cli/query-parameters-openapi-as-objects/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/query-parameters-openapi-as-objects/Cargo.toml b/seed/cli/query-parameters-openapi-as-objects/Cargo.toml index 5d3bcf79c003..a679b3c5223e 100644 --- a/seed/cli/query-parameters-openapi-as-objects/Cargo.toml +++ b/seed/cli/query-parameters-openapi-as-objects/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/query-parameters-openapi/github-npm/.github/workflows/ci.yml b/seed/cli/query-parameters-openapi/github-npm/.github/workflows/ci.yml new file mode 100644 index 000000000000..a885bbe7123d --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/.github/workflows/ci.yml @@ -0,0 +1,243 @@ +name: ci + +on: [push] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + RUSTFLAGS: "-A warnings" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Check + run: cargo check + + compile: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Compile + run: cargo build + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Test + run: cargo test + + publish: + needs: [check, compile, test] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ${{ matrix.runner }} + strategy: + # Don't cancel sibling matrix jobs on first failure — a transient + # failure on one platform would otherwise leave npm in a partial + # state (some platform packages published, others not, launcher + # never published), with no clean re-run since the already- + # published versions reject re-publish. + fail-fast: false + matrix: + include: + - rust-target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + npm-platform-suffix: linux-x64 + - rust-target: aarch64-unknown-linux-gnu + runner: ubuntu-latest + npm-platform-suffix: linux-arm64 + - rust-target: x86_64-apple-darwin + runner: macos-latest + npm-platform-suffix: darwin-x64 + - rust-target: aarch64-apple-darwin + runner: macos-latest + npm-platform-suffix: darwin-arm64 + - rust-target: x86_64-pc-windows-msvc + runner: windows-latest + npm-platform-suffix: win32-x64 + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.rust-target }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + registry-url: "https://registry.npmjs.org" + + - name: Install cross-compilation tools + if: matrix.rust-target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + + - name: Build release binary + run: cargo build --release --locked --target ${{ matrix.rust-target }} + + - name: Package and publish npm platform package + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + shell: bash + run: | + set -euo pipefail + + VERSION="${GITHUB_REF_NAME#v}" + PLATFORM_PKG="@fern/query-parameters-openapi-${{ matrix.npm-platform-suffix }}" + PKG_DIR="npm-pkg/${PLATFORM_PKG}" + mkdir -p "${PKG_DIR}" + + # Locate the compiled binary + BINARY_NAME="query-parameters-api" + if [[ "${{ matrix.rust-target }}" == *"windows"* ]]; then + BINARY_NAME="query-parameters-api.exe" + fi + cp "target/${{ matrix.rust-target }}/release/${BINARY_NAME}" "${PKG_DIR}/" + + # Write platform package.json + cat > "${PKG_DIR}/package.json" < "${PKG_DIR}/package.json" < "${PKG_DIR}/bin/cli.js" <<'LAUNCHER' + #!/usr/bin/env node + "use strict"; + const { execFileSync } = require("child_process"); + const path = require("path"); + const os = require("os"); + + const PLATFORMS = { + "linux-x64": "@fern/query-parameters-openapi-linux-x64", + "linux-arm64": "@fern/query-parameters-openapi-linux-arm64", + "darwin-x64": "@fern/query-parameters-openapi-darwin-x64", + "darwin-arm64": "@fern/query-parameters-openapi-darwin-arm64", + "win32-x64": "@fern/query-parameters-openapi-win32-x64", + }; + + const platformKey = os.platform() + "-" + os.arch(); + const pkg = PLATFORMS[platformKey]; + if (!pkg) { + console.error("Unsupported platform: " + platformKey); + process.exit(1); + } + + const binName = os.platform() === "win32" ? "query-parameters-api.exe" : "query-parameters-api"; + const binPath = path.join(require.resolve(pkg + "/package.json"), "..", binName); + + try { + execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" }); + } catch (e) { + if (e && typeof e === "object" && "status" in e) { + process.exit(e.status); + } + throw e; + } + LAUNCHER + + cd "${PKG_DIR}" + # Pre-release detection — require the semver "-" separator so a + # release tag like v1.0.0 for a package whose version string + # happens to contain "alpha"/"beta" as a substring isn't + # mis-tagged on npm. + if [[ "${VERSION}" == *-alpha* ]]; then + npm publish --access public --tag alpha + elif [[ "${VERSION}" == *-beta* ]]; then + npm publish --access public --tag beta + else + npm publish --access public + fi diff --git a/seed/cli/query-parameters-openapi/github-npm/.gitignore b/seed/cli/query-parameters-openapi/github-npm/.gitignore new file mode 100644 index 000000000000..1bb66c2ceb3c --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/.gitignore @@ -0,0 +1,4 @@ +/target +**/*.rs.bk +.DS_Store +*.swp diff --git a/seed/cli/query-parameters-openapi/github-npm/Cargo.lock b/seed/cli/query-parameters-openapi/github-npm/Cargo.lock new file mode 100644 index 000000000000..97525c932dbb --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/Cargo.lock @@ -0,0 +1,2817 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fern-cli-sdk" +version = "1.0.0" +dependencies = [ + "anyhow", + "base64", + "bytes", + "clap", + "clap_complete", + "clap_mangen", + "dotenvy", + "form_urlencoded", + "futures-util", + "hmac", + "httpdate", + "libc", + "percent-encoding", + "reqwest", + "secrecy", + "serde", + "serde_json", + "serde_json_path", + "serde_qs", + "serde_yaml", + "serial_test", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "wiremock", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_path" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b992cea3194eea663ba99a042d61cea4bd1872da37021af56f6a37e0359b9d33" +dependencies = [ + "inventory", + "nom", + "regex", + "serde", + "serde_json", + "serde_json_path_core", + "serde_json_path_macros", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_json_path_core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde67d8dfe7d4967b5a95e247d4148368ddd1e753e500adb34b3ffe40c6bc1bc" +dependencies = [ + "inventory", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_json_path_macros" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517acfa7f77ddaf5c43d5f119c44a683774e130b4247b7d3210f8924506cfac8" +dependencies = [ + "inventory", + "serde_json_path_core", + "serde_json_path_macros_internal", +] + +[[package]] +name = "serde_json_path_macros_internal" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafbefbe175fa9bf03ca83ef89beecff7d2a95aaacd5732325b90ac8c3bd7b90" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_qs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332" +dependencies = [ + "itoa", + "percent-encoding", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/seed/cli/query-parameters-openapi/github-npm/Cargo.toml b/seed/cli/query-parameters-openapi/github-npm/Cargo.toml new file mode 100644 index 000000000000..8ec57f7ed1d9 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/Cargo.toml @@ -0,0 +1,85 @@ +[package] +name = "fern-cli-sdk" +version = "1.0.0" +edition = "2021" +description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" +license = "Apache-2.0" +repository = "https://github.com/fern-api/cli-sdk" +homepage = "https://github.com/fern-api/cli-sdk" +authors = ["Fern "] +keywords = ["cli", "openapi", "graphql", "fern", "codegen"] +categories = ["command-line-utilities", "web-programming"] + +[lib] +name = "fern_cli_sdk" +path = "src/lib.rs" + +[[bin]] +name = "query-parameters-api" +path = "cli/query-parameters-api/main.rs" + +[features] +# TLS backend selection. +# +# default = ["native-tls"] +# Uses the OS's native TLS stack (Secure Transport on macOS, SChannel on +# Windows, OpenSSL on Linux). Honors the OS keychain / cert store — +# what users typically expect for an interactive CLI. +# +# ["rustls"] (cargo build --no-default-features --features rustls) +# Uses the pure-Rust rustls crate with Mozilla's bundled webpki roots. +# Produces self-contained static binaries that don't depend on system +# OpenSSL — preferred for distribution to varied Linux servers, scratch +# Docker images, and cross-compiled musl/ARM builds. Does NOT read the +# OS keychain; users must use `_CA_BUNDLE` for custom roots. +default = ["native-tls"] +native-tls = ["reqwest/native-tls", "tokio-tungstenite/native-tls"] +rustls = ["reqwest/rustls-tls-native-roots", "tokio-tungstenite/rustls-tls-native-roots"] + +[dependencies] +anyhow = "1" +base64 = "0.22" +bytes = "1" +clap = { version = "4", features = ["derive", "string", "env"] } +clap_complete = "4" +clap_mangen = "0.2" +hmac = "0.12" +dotenvy = "0.15" +futures-util = "0.3" +httpdate = "1" +libc = "0.2" +percent-encoding = "2.3.2" +reqwest = { version = "0.12", features = ["json", "stream"], default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_json_path = "0.7" +serde_yaml = "0.9.34" +secrecy = "0.10" +serde_qs = "1.1.1" +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake"] } +tokio-util = { version = "0.7", features = ["io"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" +form_urlencoded = "1" + +[package.metadata.dist] +dist = true + +# The profile that 'cargo dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" + +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + +[dev-dependencies] +serial_test = "3.4.0" +tempfile = "3" +wiremock = "0.6" +tokio = { version = "1", features = ["full"] } diff --git a/seed/cli/query-parameters-openapi/github-npm/LICENSE b/seed/cli/query-parameters-openapi/github-npm/LICENSE new file mode 100644 index 000000000000..d64569567334 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/seed/cli/query-parameters-openapi/github-npm/cli/query-parameters-api/main.rs b/seed/cli/query-parameters-openapi/github-npm/cli/query-parameters-api/main.rs new file mode 100644 index 000000000000..255a5e98adfe --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/cli/query-parameters-api/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("query-parameters-api") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/query-parameters-openapi/github-npm/cli/query-parameters-api/openapi0.json b/seed/cli/query-parameters-openapi/github-npm/cli/query-parameters-api/openapi0.json new file mode 100644 index 000000000000..1374bec8f0ae --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/cli/query-parameters-api/openapi0.json @@ -0,0 +1 @@ +{"openapi":"3.0.0","info":{"title":"Query Parameters API","version":"1.0.0"},"paths":{"/user/getUsername":{"get":{"operationId":"search","parameters":[{"name":"limit","in":"query","required":true,"schema":{"type":"integer"}},{"name":"id","in":"query","required":true,"schema":{"type":"string"}},{"name":"date","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"deadline","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"bytes","in":"query","required":true,"schema":{"type":"string","format":"base64"}},{"name":"user","in":"query","required":true,"schema":{"$ref":"#/components/schemas/User"}},{"name":"userList","in":"query","required":true,"schema":{"type":"array","items":{"$ref":"#/components/schemas/User"}}},{"name":"optionalDeadline","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"keyValue","in":"query","required":false,"schema":{"type":"object","additionalProperties":{"type":"string"}}},{"name":"optionalString","in":"query","required":false},{"name":"nestedUser","in":"query","required":false,"schema":{"$ref":"#/components/schemas/NestedUser"}},{"name":"optionalUser","in":"query","required":false,"schema":{"$ref":"#/components/schemas/User"}},{"name":"excludeUser","in":"query","required":false,"schema":{"type":"array","items":{"$ref":"#/components/schemas/User"}},"explode":true},{"name":"filter","in":"query","required":false,"schema":{"type":"array","items":{"type":"string"}},"explode":true},{"name":"tags","in":"query","required":true,"description":"List of tags. Serialized as a comma-separated list.","style":"form","explode":false,"schema":{"type":"array","items":{"type":"string"}},"example":["ACCESS_GRANTED","COPY"]},{"name":"optionalTags","in":"query","required":false,"description":"Optional list of tags. Serialized as a comma-separated list.","style":"form","explode":false,"schema":{"type":"array","items":{"type":"string"}},"example":["DELETE","MOVE"]},{"name":"neighbor","in":"query","required":false,"schema":{"oneOf":[{"$ref":"#/components/schemas/User"},{"$ref":"#/components/schemas/NestedUser"},{"type":"string"},{"type":"integer"}]}},{"name":"neighborRequired","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/User"},{"$ref":"#/components/schemas/NestedUser"},{"type":"string"},{"type":"integer"}]}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"results":{"type":"array","items":{"type":"string"}}}}}}}}}}},"components":{"schemas":{"User":{"type":"object","properties":{"name":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}}}},"NestedUser":{"type":"object","properties":{"name":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}}}}}} \ No newline at end of file diff --git a/seed/cli/query-parameters-openapi/github-npm/dist-workspace.toml b/seed/cli/query-parameters-openapi/github-npm/dist-workspace.toml new file mode 100644 index 000000000000..db9541483dde --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/dist-workspace.toml @@ -0,0 +1,34 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.31.0" +# CI backends to support +ci = "github" +# Build each app with `cargo build --package` instead of a workspace-wide +# build. Without this, cargo-dist also compiles the non-distributed root +# crate, whose default features pull in native-tls/OpenSSL and break the +# musl builds. +precise-builds = true +# The installers to generate for each app +installers = ["shell", "powershell", "npm"] +# Whether to enable GitHub Attestations +github-attestations = true +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] +# Which actions to run on pull requests +pr-run-mode = "plan" +# Publish jobs to run (npm publishing deferred until pipeline is validated) +publish-jobs = [] +# Don't overwrite release.yml on `dist init` (preserves customizations) +allow-dirty = ["ci"] +# The archive format to use for windows builds (defaults .zip) +windows-archive = ".zip" +# The archive format to use for non-windows builds (defaults .tar.xz) +unix-archive = ".tar.gz" +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = false diff --git a/seed/cli/query-parameters-openapi/github-npm/src/app.rs b/seed/cli/query-parameters-openapi/github-npm/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/app.rs @@ -0,0 +1,851 @@ +//! Root-level `CliApp` that composes one or more [`Binding`]s into a +//! single CLI binary. +//! +//! **Architectural rule:** `CliApp::run()` always runs the full dispatch +//! pipeline. There is no single-binding shortcut. A binary with one +//! binding goes through exactly the same pipeline as a binary with five. +//! +//! The pipeline: +//! 1. Parse argv → `ArgMatches` +//! 2. Resolve operation path → matched `Binding` +//! 3. Call `Binding::dispatch(...)` (fires transport-scope hooks) +//! 4. Run CliApp-scope `transform_response` chain +//! 5. On error from step 3, run CliApp-scope `recover_error` chain +//! 6. Format and write output +//! +//! See [PR #62 review](https://github.com/fern-api/cli-sdk/pull/62#issuecomment-4484622766) +//! for why the single-binding fast path was removed. + +use std::any::Any; + +use serde_json::Value; + +use crate::auth::root_builder::AuthSchemeBuilder; +use crate::auth::SchemeBinding; +use crate::binding::{Binding, DispatchResult}; +use crate::error::{write_error_json, CliError}; +use crate::formatter; +use crate::hooks::HookRegistry; +use crate::stability::Stability; + +/// Handler function for CLI-level custom commands. +/// +/// Receives the parsed [`clap::ArgMatches`] for the subcommand and a +/// type-erased binding context. Use [`OpenApiBinding::handler()`] or +/// [`GraphqlBinding::handler()`] to wrap a typed handler function +/// instead of downcasting manually. +/// +/// [`OpenApiBinding::handler()`]: crate::openapi::OpenApiBinding::handler +/// [`GraphqlBinding::handler()`]: crate::graphql::GraphqlBinding::handler +pub type CliCommandHandler = + Box Result<(), CliError> + Send + Sync>; + +/// A CLI-level custom command: parent path, clap command, and handler. +struct CliCommand { + path: Vec, + cmd: clap::Command, + handler: CliCommandHandler, +} + +/// Outcome of the dispatch pipeline — separates success from +/// help/version display so `CliError` is reserved for real errors. +enum PipelineOutcome { + Success, + HelpShown, +} + +// ── Tier 1 deferred operations ────────────────────────────────────── + +/// A declarative modification to be applied to the clap command tree +/// after all bindings have contributed their subtrees. +enum DeferredOp { + Alias { + path: Vec, + alias: String, + }, + Hide { + path: Vec, + }, + Stability { + path: Vec, + stability: Stability, + }, +} + +// ── Root CliApp ───────────────────────────────────────────────────── + +/// Root-level CLI application builder that composes [`Binding`]s. +/// +/// ```rust,ignore +/// use fern_cli_sdk::app::CliApp; +/// use fern_cli_sdk::openapi::OpenApiBinding; +/// +/// fn main() { +/// CliApp::new("my-cli") +/// .title("My CLI") +/// .description("Interact with the My API from the command line.") +/// .binding( +/// OpenApiBinding::new() +/// .spec(include_str!("openapi.yaml")) +/// .auth_scheme_env("bearer", "MY_API_KEY"), +/// ) +/// .run() +/// } +/// ``` +#[must_use] +pub struct CliApp { + name: String, + title: Option, + description: Option, + bindings: Vec>, + hooks: HookRegistry, + deferred_ops: Vec, + cli_commands: Vec, + /// Root-level auth scheme bindings. These are shared across all + /// bindings — each binding's spec references schemes by name and + /// the credential source is looked up from this registry. + auth_bindings: Vec<(String, SchemeBinding)>, +} + +impl CliApp { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + title: None, + description: None, + bindings: Vec::new(), + hooks: HookRegistry::new(), + deferred_ops: Vec::new(), + cli_commands: Vec::new(), + auth_bindings: Vec::new(), + } + } + + // ── CLI metadata ──────────────────────────────────────────────── + + /// Set the top-level `--help` title for this CLI. + pub fn title(mut self, t: &str) -> Self { + self.title = Some(t.to_string()); + self + } + + /// Set the top-level `--help` description for this CLI. + pub fn description(mut self, d: &str) -> Self { + self.description = Some(d.to_string()); + self + } + + // ── Binding registration ──────────────────────────────────────── + + /// Add a binding (protocol adapter) to this CLI. The CLI name is + /// propagated to the binding for HTTP config, logging, and base-URL + /// resolution. + pub fn binding(mut self, mut binding: impl Binding + 'static) -> Self { + binding.set_cli_name(&self.name); + self.bindings.push(Box::new(binding)); + self + } + + // ── Auth registration ──────────────────────────────────────────── + + /// Register an auth scheme at the root CLI level. + /// + /// Auth declared here is shared across all bindings. Each binding's + /// spec references schemes by name (from its `securitySchemes`), and + /// credential resolution comes from this root registry. + /// + /// ```rust,ignore + /// use fern_cli_sdk::app::CliApp; + /// use fern_cli_sdk::auth::{BearerAuth, ApiKeyAuth}; + /// + /// CliApp::new("my-cli") + /// .auth(BearerAuth::new("bearerAuth").env("MY_TOKEN")) + /// .auth(ApiKeyAuth::new("apiKey").env("API_KEY")) + /// .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) + /// .run() + /// ``` + pub fn auth(mut self, builder: impl AuthSchemeBuilder) -> Self { + self.auth_bindings.push(builder.into_binding()); + self + } + + // ── Custom commands ────────────────────────────────────────────── + + /// Register a top-level custom command. + /// + /// Use [`OpenApiBinding::handler()`] or [`GraphqlBinding::handler()`] + /// to wrap a typed handler that receives the concrete binding context: + /// + /// ```rust,ignore + /// CliApp::new("my-cli") + /// .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) + /// .command(my_command(), OpenApiBinding::handler(my_handler)) + /// .run() + /// ``` + /// + /// **Note:** `transform_response` and `recover_error` hooks do not + /// apply to custom commands. Custom command handlers manage their + /// own output directly. + /// + /// [`OpenApiBinding::handler()`]: crate::openapi::OpenApiBinding::handler + /// [`GraphqlBinding::handler()`]: crate::graphql::GraphqlBinding::handler + pub fn command(mut self, cmd: clap::Command, handler: CliCommandHandler) -> Self { + self.cli_commands.push(CliCommand { + path: Vec::new(), + cmd, + handler, + }); + self + } + + /// Register a custom command under an existing command path. + /// + /// ```rust,ignore + /// CliApp::new("my-cli") + /// .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) + /// .command_under( + /// &["webhooks"], + /// verify_command(), + /// OpenApiBinding::handler(handle_verify), + /// ) + /// .run() + /// ``` + /// + /// **Note:** `transform_response` and `recover_error` hooks do not + /// apply to custom commands. Custom command handlers manage their + /// own output directly. + pub fn command_under( + mut self, + path: &[&str], + cmd: clap::Command, + handler: CliCommandHandler, + ) -> Self { + self.cli_commands.push(CliCommand { + path: path.iter().map(|s| s.to_string()).collect(), + cmd, + handler, + }); + self + } + + // ── Tier 1: Declarative ───────────────────────────────────────── + + /// Register an alias for a command at `path`. Invoking the alias + /// produces the same output as the canonical name. + pub fn alias(mut self, path: &[&str], alias: &str) -> Self { + self.deferred_ops.push(DeferredOp::Alias { + path: path.iter().map(|s| s.to_string()).collect(), + alias: alias.to_string(), + }); + self + } + + /// Hide a command from `--help` output. + pub fn hide(mut self, path: &[&str]) -> Self { + self.deferred_ops.push(DeferredOp::Hide { + path: path.iter().map(|s| s.to_string()).collect(), + }); + self + } + + /// Set the stability level for a command. + pub fn stability(mut self, path: &[&str], stability: Stability) -> Self { + self.deferred_ops.push(DeferredOp::Stability { + path: path.iter().map(|s| s.to_string()).collect(), + stability, + }); + self + } + + /// Mark a command as deprecated with a message. + pub fn deprecate(self, path: &[&str], message: &str) -> Self { + self.stability( + path, + Stability::Deprecated { + message: message.to_string(), + replacement: None, + removed_in: None, + }, + ) + } + + // ── Tier 2: Per-command hooks ─────────────────────────────────── + + /// Transform a decoded response value before format/output. + /// Glob path applies across many operations. + pub fn transform_response(mut self, path: &[&str], f: F) -> Self + where + F: Fn(Value, Vec) -> Fut + Send + Sync + 'static, + Fut: std::future::Future> + Send + 'static, + { + self.hooks.add_transform_response( + path, + Box::new(move |v, p| Box::pin(f(v, p))), + ); + self + } + + /// Convert an API error into synthetic success. Returning + /// `Ok(Some(v))` short-circuits with `v` as the response; + /// `Ok(None)` lets the error propagate. + pub fn recover_error(mut self, path: &[&str], f: F) -> Self + where + F: Fn(CliError, Vec) -> Fut + Send + Sync + 'static, + Fut: std::future::Future, CliError>> + Send + 'static, + { + self.hooks.add_recover_error( + path, + Box::new(move |e, p| Box::pin(f(e, p))), + ); + self + } + + // ── Run ───────────────────────────────────────────────────────── + + /// Run the CLI, consuming `self`. Builds the command tree, parses + /// argv, dispatches through the matched binding, applies hooks, + /// and formats output. + pub fn run(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let mut out = std::io::stdout().lock(); + let exit = rt.block_on(self.run_inner(std::env::args_os().collect(), &mut out)); + drop(out); + std::process::exit(exit); + } + + /// Testable entry point: runs the full pipeline against the given + /// argv and returns the exit code instead of calling + /// `std::process::exit`. Output is written to stdout. + pub fn try_run_from(mut self, args: I) -> i32 + where + I: IntoIterator, + T: Into, + { + self.propagate_root_auth(); + let args: Vec = args.into_iter().map(Into::into).collect(); + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let mut out = std::io::stdout().lock(); + rt.block_on(self.run_inner(args, &mut out)) + } + + /// Testable entry point that captures output into the provided + /// writer instead of stdout. Returns `(exit_code, bytes_written)`. + /// + /// This is the preferred method for behavior tests — it avoids + /// process-global stdout redirection (`gag`) which is racy under + /// parallel test execution. + pub fn try_run_from_with_output(mut self, args: I, out: &mut W) -> i32 + where + I: IntoIterator, + T: Into, + W: std::io::Write, + { + self.propagate_root_auth(); + let args: Vec = args.into_iter().map(Into::into).collect(); + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + rt.block_on(self.run_inner(args, out)) + } + + /// Pass root-level auth bindings to each registered binding and + /// validate that specs don't reference unregistered schemes. + /// Must be called before `run_inner` / `dispatch_pipeline`. + fn propagate_root_auth(&mut self) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// Validate auth across all bindings. Hard-errors if any binding's + /// spec references a scheme not registered in auth_bindings. + fn validate_auth(&self) -> Result<(), CliError> { + for binding in &self.bindings { + binding.validate_auth()?; + } + Ok(()) + } + + /// Core async pipeline. Returns exit code (0 = success). + /// + /// **NO SINGLE-BINDING SHORTCUT.** Every execution path goes through + /// the full dispatch pipeline regardless of binding count. + async fn run_inner(&self, args: Vec, out: &mut W) -> i32 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + err.exit_code() + } + } + } + + /// The full dispatch pipeline. + async fn dispatch_pipeline( + &self, + args: Vec, + out: &mut W, + ) -> Result { + if self.bindings.is_empty() { + return Err(CliError::Discovery( + "No bindings registered. Call .binding() on CliApp.".to_string(), + )); + } + + // 0. Validate auth bindings — hard error if a binding's spec + // references a scheme not registered at root. + self.validate_auth()?; + + // 0. Convert args to strings for early interception checks. + let str_args: Vec = args.iter() + .filter_map(|a| a.to_str().map(String::from)) + .collect(); + + // 0a. Intercept ` errors` early — before loading specs. + if crate::cli_args::is_errors_subcommand(&str_args) { + crate::error::write_errors_to(&str_args, out); + return Ok(PipelineOutcome::HelpShown); + } + + // 0b. Intercept `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 1. Build merged command tree from all bindings. + let mut cli = clap::Command::new(self.name.clone()) + .version(env!("CARGO_PKG_VERSION")) + .arg_required_else_help(true) + .subcommand_required(true) + .term_width(200); + if let Some(ref t) = self.title { + cli = cli.about(t.clone()); + } + if let Some(ref d) = self.description { + cli = cli.long_about(d.clone()); + } + cli = cli + .arg( + clap::Arg::new("format") + .long("format") + .help("Output format: json (default), table, yaml, csv") + .value_name("FORMAT") + .global(true), + ) + .arg( + clap::Arg::new("base-url") + .long("base-url") + .help("Override the API base URL (e.g. for testing against a mock server)") + .value_name("URL") + .global(true), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + for arg in subcmd.get_arguments() { + let id = arg.get_id().as_str(); + if !seen_arg_ids.insert(id.to_string()) { + continue; + } + cli = cli.arg(arg.clone()); + } + // Carry the binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 1b. Register CLI-level custom commands (may be nested). + for cc in &self.cli_commands { + cli = crate::custom_commands::graft_subcommand(cli, &cc.path, cc.cmd.clone()); + } + + // 1c. Register `completion` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_command()); + + // 1d. Apply Tier 1 deferred operations (alias, hide, stability) + // before completion/man generation so aliases appear in tab- + // completion scripts and man pages reflect hidden/stability state. + for op in &self.deferred_ops { + match op { + DeferredOp::Alias { path, alias } => { + cli = apply_alias(cli, path, alias); + } + DeferredOp::Hide { path } => { + cli = apply_hide(cli, path); + } + DeferredOp::Stability { path, stability } => { + cli = apply_stability(cli, path, stability); + } + } + } + + // 1e. Validate hook patterns against the command tree. + self.hooks.validate_patterns(&cli)?; + + // 1f. Intercept `completion` and `man` before clap parses. + if crate::completions::wants_completion(&str_args) { + let raw_shell_arg = + crate::early_intercept::nth_positional(&str_args, 1); + match raw_shell_arg { + Some(s) => match crate::completions::parse_shell(s) { + Some(shell) => { + crate::completions::generate_completion_to(shell, &mut cli, &self.name, out) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(PipelineOutcome::HelpShown); + } + None => { + return Err(CliError::Validation(format!( + "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" + ))); + } + }, + None => { + if let Some(sub) = cli.find_subcommand_mut("completion") { + let _ = sub.write_help(out); + } + return Ok(PipelineOutcome::HelpShown); + } + } + } + if crate::man::wants_man(&str_args) { + let has_help = str_args.iter().skip(1) + .skip_while(|a| a.as_str() != "man").skip(1) + .any(|a| a == "--help" || a == "-h"); + if has_help { + if let Some(sub) = cli.find_subcommand_mut("man") { + let _ = sub.write_help(out); + } + return Ok(PipelineOutcome::HelpShown); + } + crate::man::generate_man_to(cli, &self.name, out) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(PipelineOutcome::HelpShown); + } + + // 3. Parse argv. + let matches = match cli.try_get_matches_from(&args) { + Ok(m) => m, + Err(e) + if e.kind() == clap::error::ErrorKind::DisplayHelp + || e.kind() + == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + || e.kind() == clap::error::ErrorKind::DisplayVersion => + { + let _ = std::io::Write::write_fmt(out, format_args!("{e}")); + let _ = out.flush(); + return Ok(PipelineOutcome::HelpShown); + } + Err(e) => return Err(CliError::Validation(e.to_string())), + }; + + // 4. Resolve which binding owns the matched subcommand. + let (op_path, sub_matches) = resolve_op_path(&matches); + + // 4a. Check CLI-level custom commands first. + for cc in &self.cli_commands { + if let Some(target) = crate::custom_commands::walk_matches_to_custom( + &matches, &cc.path, cc.cmd.get_name(), + ) { + // Collect contexts from ALL bindings so the handler can + // invoke operations from any binding transparently. + let mut ctx: Option> = None; + for b in &self.bindings { + ctx = b.merge_binding_context(&matches, ctx)?; + } + let ctx = ctx.unwrap_or_else(|| Box::new(())); + (cc.handler)(target, ctx.as_ref())?; + return Ok(PipelineOutcome::Success); + } + } + + let binding_idx = resolve_binding_for_path( + &op_path, + &binding_commands, + ).ok_or_else(|| { + CliError::Discovery(format!( + "No binding found for command path: {}", + op_path.join(" "), + )) + })?; + + // 5. Dispatch to the binding. NO SHORTCUT — always goes through + // the full pipeline. + let dispatch_result = self.bindings[binding_idx] + .dispatch(&matches, sub_matches, &op_path) + .await; + + // 6. Apply CliApp-scope hooks. + match dispatch_result { + Ok(DispatchResult::Value(value)) => { + // Run transform_response chain. + let transformed = self.hooks.run_transform_response(value, &op_path).await?; + + // Format and write output. + let pipeline = formatter::OutputPipeline::from_matches(&matches) + .map_err(|e| CliError::Validation(e.to_string()))?; + pipeline + .emit(out, &transformed, false, true) + .map_err(|e| CliError::Other(e.into()))?; + Ok(PipelineOutcome::Success) + } + Ok(DispatchResult::Handled) => { + // Binding already handled output (dry-run, streaming, etc.). + Ok(PipelineOutcome::Success) + } + Err(err) => { + // Run recover_error chain. + if self.hooks.has_recover_error() { + match self.hooks.run_recover_error(err, &op_path).await { + Ok(value) => { + let pipeline = formatter::OutputPipeline::from_matches(&matches) + .map_err(|e| CliError::Validation(e.to_string()))?; + pipeline + .emit(out, &value, false, true) + .map_err(|e| CliError::Other(e.into()))?; + Ok(PipelineOutcome::Success) + } + Err(e) => Err(e), + } + } else { + Err(err) + } + } + } + } +} + +// ── Command tree helpers ──────────────────────────────────────────── + +/// Walk the `ArgMatches` subcommand chain to extract the operation path +/// and the leaf subcommand's matches. +fn resolve_op_path(matches: &clap::ArgMatches) -> (Vec, &clap::ArgMatches) { + let mut path = Vec::new(); + let mut current = matches; + while let Some((name, sub)) = current.subcommand() { + path.push(name.to_string()); + current = sub; + } + (path, current) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// Apply a transform to the command at `path` using clap's +/// `mut_subcommand` to walk the tree. Parent commands are never +/// rebuilt — only the leaf is transformed — so all clap settings on +/// every ancestor are preserved automatically, regardless of what +/// settings clap adds in future versions. +fn modify_at_path( + cmd: clap::Command, + path: &[String], + transform: &dyn Fn(clap::Command) -> clap::Command, +) -> clap::Command { + if path.is_empty() { + return transform(cmd); + } + let head = path[0].clone(); + let rest = path[1..].to_vec(); + cmd.mut_subcommand(head, move |sub| modify_at_path(sub, &rest, transform)) +} + +/// Apply a clap alias to the command at `path`. +fn apply_alias(cli: clap::Command, path: &[String], alias: &str) -> clap::Command { + let alias_owned = alias.to_string(); + modify_at_path(cli, path, &|c| c.visible_alias(alias_owned.clone())) +} + +/// Apply `hide(true)` to the command at `path`. +fn apply_hide(cli: clap::Command, path: &[String]) -> clap::Command { + modify_at_path(cli, path, &|c| c.hide(true)) +} + +/// Apply a stability badge to the command at `path`. +fn apply_stability(cli: clap::Command, path: &[String], stability: &Stability) -> clap::Command { + modify_at_path(cli, path, &|c| { + if let Some(badge) = stability.badge() { + let about = c + .get_about() + .map(|a| format!("{badge} {a}")) + .unwrap_or_else(|| badge.to_string()); + c.about(about) + } else { + c + } + }) +} + +/// Merge multiple `after_help` sections, deduplicating identical blocks +/// while preserving first-seen order. Blocks are delimited by blank +/// lines (`\n\n`). This handles multi-line entries (e.g. auth sections +/// spanning several lines) as atomic units — they're either kept or +/// dropped as a whole, never split. +fn deduplicate_after_help(sections: &[String]) -> String { + let mut seen = std::collections::HashSet::new(); + let mut blocks = Vec::new(); + for section in sections { + // Split each section into blank-line-delimited blocks. + for block in section.split("\n\n") { + let trimmed = block.trim(); + if !trimmed.is_empty() && seen.insert(trimmed.to_string()) { + blocks.push(trimmed.to_string()); + } + } + } + blocks.join("\n\n") +} + +// ── Tests ─────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_op_path_extracts_chain() { + let cmd = clap::Command::new("test") + .subcommand( + clap::Command::new("users").subcommand(clap::Command::new("get")), + ); + let matches = cmd + .try_get_matches_from(["test", "users", "get"]) + .unwrap(); + let (path, _) = resolve_op_path(&matches); + assert_eq!(path, vec!["users".to_string(), "get".to_string()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), None); + } + + #[test] + fn cli_app_must_use() { + // This test verifies the builder compiles — #[must_use] + // would fire a warning if the value were dropped without use. + let _app = CliApp::new("test"); + } + + #[test] + fn deduplicate_after_help_removes_identical_blocks() { + let a = "Environment variables:\n BOX_BASE_URL Override\n BOX_CA_BUNDLE Path".to_string(); + let b = "Environment variables:\n BOX_BASE_URL Override\n BOX_CA_BUNDLE Path".to_string(); + let result = deduplicate_after_help(&[a, b]); + assert_eq!( + result, + "Environment variables:\n BOX_BASE_URL Override\n BOX_CA_BUNDLE Path", + ); + } + + #[test] + fn deduplicate_after_help_preserves_unique_blocks() { + let a = "Auth:\n bearer via API_KEY".to_string(); + let b = "Environment variables:\n BOX_BASE_URL Override".to_string(); + let result = deduplicate_after_help(&[a, b]); + assert_eq!( + result, + "Auth:\n bearer via API_KEY\n\nEnvironment variables:\n BOX_BASE_URL Override", + ); + } + + #[test] + fn deduplicate_after_help_multiline_blocks_are_atomic() { + // Two bindings with identical multi-line env block but + // different auth blocks — env block appears once, both auth kept. + let env_block = "Environment variables:\n BOX_BASE_URL Override\n BOX_CA_BUNDLE Path"; + let a = format!("Auth:\n bearer via API_KEY\n\n{env_block}"); + let b = format!("Auth:\n basic via SECRET\n\n{env_block}"); + let result = deduplicate_after_help(&[a, b]); + assert_eq!( + result, + format!("Auth:\n bearer via API_KEY\n\n{env_block}\n\nAuth:\n basic via SECRET"), + ); + } + + #[test] + fn deduplicate_after_help_real_world_footer() { + // Simulates two bindings with the same binary name producing + // identical env var + standard-env-var blocks. + let section = "Environment variables:\n BOX_BASE_URL Override\n BOX_TIMEOUT_SECS Timeout\n\nStandard env vars are also honored."; + let result = deduplicate_after_help(&[section.to_string(), section.to_string()]); + assert_eq!(result, section); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/arg_source.rs b/seed/cli/query-parameters-openapi/github-npm/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/arg_source.rs @@ -0,0 +1,229 @@ +//! Strategy trait for argument defaults. +//! +//! [`ArgSource`] resolves a default value for a CLI flag at runtime. +//! Named implementations cover env vars, files, literals, and chains. + +use serde_json::Value; + +use crate::binding::BoxFuture; +use crate::error::CliError; + +/// Async strategy for resolving a default argument value. +pub trait ArgSource: Send + Sync + 'static { + /// Resolve the default value. `None` means "no default available." + fn resolve(&self) -> BoxFuture<'_, Result, CliError>>; +} + +/// Read a trimmed env var. Empty string → `None`. +pub struct EnvArg { + var: String, +} + +impl EnvArg { + pub fn new(var: impl Into) -> Self { + Self { var: var.into() } + } +} + +impl ArgSource for EnvArg { + fn resolve(&self) -> BoxFuture<'_, Result, CliError>> { + Box::pin(async move { + match std::env::var(&self.var) { + Ok(v) => { + let trimmed = v.trim().to_string(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(Value::String(trimmed))) + } + } + Err(_) => Ok(None), + } + }) + } +} + +/// Read and trim file contents. Missing file → `None`. `~` is expanded +/// against `$HOME`. +pub struct FileArg { + path: std::path::PathBuf, +} + +impl FileArg { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + fn expand_tilde(path: &std::path::Path) -> std::path::PathBuf { + if let Ok(stripped) = path.strip_prefix("~") { + if let Ok(home) = std::env::var("HOME") { + return std::path::PathBuf::from(home).join(stripped); + } + } + path.to_path_buf() + } +} + +impl ArgSource for FileArg { + fn resolve(&self) -> BoxFuture<'_, Result, CliError>> { + let expanded = Self::expand_tilde(&self.path); + Box::pin(async move { + match tokio::fs::read_to_string(&expanded).await { + Ok(contents) => { + let trimmed = contents.trim().to_string(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(Value::String(trimmed))) + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(CliError::Other(anyhow::anyhow!( + "Failed to read {}: {e}", + expanded.display() + ))), + } + }) + } +} + +/// A baked-in default value. +pub struct LiteralArg { + value: Value, +} + +impl LiteralArg { + pub fn new(value: impl Into) -> Self { + Self { + value: value.into(), + } + } +} + +impl ArgSource for LiteralArg { + fn resolve(&self) -> BoxFuture<'_, Result, CliError>> { + let v = self.value.clone(); + Box::pin(async move { Ok(Some(v)) }) + } +} + +/// First source returning `Some` wins. +pub struct ChainArg { + sources: Vec>, +} + +impl ChainArg { + pub fn from_sources(sources: Vec>) -> Self { + Self { sources } + } +} + +impl ArgSource for ChainArg { + fn resolve(&self) -> BoxFuture<'_, Result, CliError>> { + Box::pin(async move { + for source in &self.sources { + if let Some(v) = source.resolve().await? { + return Ok(Some(v)); + } + } + Ok(None) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn env_arg_reads_value() { + std::env::set_var("TEST_ARG_SOURCE_1", "hello"); + let source = EnvArg::new("TEST_ARG_SOURCE_1"); + let result = source.resolve().await.unwrap(); + assert_eq!(result, Some(Value::String("hello".into()))); + std::env::remove_var("TEST_ARG_SOURCE_1"); + } + + #[tokio::test] + async fn env_arg_empty_returns_none() { + std::env::set_var("TEST_ARG_SOURCE_2", " "); + let source = EnvArg::new("TEST_ARG_SOURCE_2"); + let result = source.resolve().await.unwrap(); + assert_eq!(result, None); + std::env::remove_var("TEST_ARG_SOURCE_2"); + } + + #[tokio::test] + async fn env_arg_missing_returns_none() { + let source = EnvArg::new("TEST_ARG_SOURCE_DEFINITELY_MISSING"); + let result = source.resolve().await.unwrap(); + assert_eq!(result, None); + } + + #[tokio::test] + async fn file_arg_reads_and_trims() { + let dir = std::env::temp_dir().join("fern_test_arg_source"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("test_file.txt"); + std::fs::write(&path, " world \n").unwrap(); + let source = FileArg::new(&path); + let result = source.resolve().await.unwrap(); + assert_eq!(result, Some(Value::String("world".into()))); + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn file_arg_missing_returns_none() { + let source = FileArg::new("/tmp/fern_test_nonexistent_file_arg_source"); + let result = source.resolve().await.unwrap(); + assert_eq!(result, None); + } + + #[tokio::test] + async fn file_arg_empty_returns_none() { + let dir = std::env::temp_dir().join("fern_test_arg_source"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("test_empty_file.txt"); + std::fs::write(&path, " \n").unwrap(); + let source = FileArg::new(&path); + let result = source.resolve().await.unwrap(); + assert_eq!(result, None); + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn literal_arg() { + let source = LiteralArg::new(42); + let result = source.resolve().await.unwrap(); + assert_eq!(result, Some(Value::Number(42.into()))); + } + + #[tokio::test] + async fn chain_arg_first_wins() { + std::env::set_var("TEST_CHAIN_ARG_1", "from-env"); + let chain = ChainArg::from_sources(vec![ + Box::new(EnvArg::new("TEST_CHAIN_ARG_1")), + Box::new(LiteralArg::new("fallback")), + ]); + let result = chain.resolve().await.unwrap(); + assert_eq!(result, Some(Value::String("from-env".into()))); + std::env::remove_var("TEST_CHAIN_ARG_1"); + } + + #[tokio::test] + async fn chain_arg_falls_through() { + let chain = ChainArg::from_sources(vec![ + Box::new(EnvArg::new("TEST_CHAIN_MISSING_ENV")), + Box::new(LiteralArg::new("fallback")), + ]); + let result = chain.resolve().await.unwrap(); + assert_eq!(result, Some(Value::String("fallback".into()))); + } + + #[tokio::test] + async fn chain_arg_empty_returns_none() { + let chain = ChainArg::from_sources(vec![]); + let result = chain.resolve().await.unwrap(); + assert_eq!(result, None); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/builder.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/builder.rs new file mode 100644 index 000000000000..e629dd01553d --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/builder.rs @@ -0,0 +1,861 @@ +//! Builder bindings: how the `CliApp` builder records "bind credential X to +//! scheme Y" before the doc is parsed, and how those bindings are lowered +//! into a concrete [`DynAuthProvider`] once the doc is available. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::auth::compose::{AllAuthProvider, AnyAuthProvider, RoutingAuthProvider}; +use crate::auth::credential::AuthCredentialSource; +use crate::auth::provider::{DynAuthProvider, NoAuthProvider}; +use crate::auth::schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; + +/// How the bound auth schemes should compose into a single +/// [`DynAuthProvider`]. Generators that already know their API's auth +/// model can pick the right strategy explicitly; hand-written CLIs can +/// rely on `Auto` and let the spec decide. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AuthStrategy { + /// Default: derive the strategy from the spec. If any operation + /// declares per-endpoint `security:`, use [`Routing`](Self::Routing); + /// otherwise use [`Any`](Self::Any). Matches the behaviour from before + /// `auth_strategy()` existed. + #[default] + Auto, + /// Try each scheme in registration order; first one with credentials + /// applies. The "any of" semantics — common when an API accepts + /// multiple equivalent auth methods (e.g., bearer or API key). + Any, + /// Apply *every* scheme to every request. The "and" semantics — used + /// when an API requires multiple schemes simultaneously (e.g., HMAC + /// signature plus an API key). + All, + /// Per-endpoint dispatch via the operation's `security_requirements`. + /// Falls back to an [`AnyAuthProvider`] over the bound schemes for + /// operations that didn't declare requirements. If the spec has no + /// per-endpoint security at all, this behaves identically to `Any`. + Routing, +} + +/// How a builder caller has bound credentials to a scheme name. +#[derive(Clone)] +pub enum SchemeBinding { + /// Single-value source — bearer / apiKey / oauth2 schemes. + Token(AuthCredentialSource), + /// Two-value source — http basic. Both must resolve for the provider + /// to claim credentials. + Basic { + username: AuthCredentialSource, + password: AuthCredentialSource, + }, + /// Caller built their own provider. Used as-is. Bypasses the + /// spec→provider lowering, so the binding's `name` is purely a routing + /// key into [`RoutingAuthProvider`]. + Custom(DynAuthProvider), +} + +impl std::fmt::Debug for SchemeBinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SchemeBinding::Token(s) => f.debug_tuple("Token").field(s).finish(), + SchemeBinding::Basic { .. } => f.write_str("Basic { .. }"), + SchemeBinding::Custom(p) => write!(f, "Custom({})", p.name()), + } + } +} + +impl SchemeBinding { + /// Walk the binding's credential sources for every CLI arg name they + /// reference. CliApp uses this before clap parsing to register the + /// corresponding global `--` flags. `Custom` bindings are opaque + /// (the user owns the provider) so they contribute nothing here. + pub fn cli_args(&self) -> Vec<&str> { + match self { + SchemeBinding::Token(src) => src.cli_args(), + SchemeBinding::Basic { username, password } => { + let mut out = username.cli_args(); + out.extend(password.cli_args()); + out + } + SchemeBinding::Custom(_) => Vec::new(), + } + } + + /// Finalize the binding's credential sources against the parsed clap + /// matches — replaces any `Cli(name)` variants with closures that read + /// from `matches`. Pass-through for `Custom` (the user already owns + /// the resolution path). + pub fn finalize(self, matches: &Arc) -> Self { + match self { + SchemeBinding::Token(src) => SchemeBinding::Token(src.finalize(matches)), + SchemeBinding::Basic { username, password } => SchemeBinding::Basic { + username: username.finalize(matches), + password: password.finalize(matches), + }, + SchemeBinding::Custom(p) => SchemeBinding::Custom(p), + } + } +} + +/// Render a human-readable "Authentication:" section for `--help` +/// describing each binding's scheme name and where it reads its value +/// from. Returns `None` when there are no bindings (caller can omit the +/// section entirely). +/// +/// The output looks like: +/// +/// ```text +/// Authentication: +/// bearerAuth API_TOKEN env var +/// apiKey --api-key flag / API_KEY env var / ~/.api/key file +/// ``` +/// +/// CLI flags and file paths are described in human terms. Closures and +/// the `Custom` binding are reported as "custom" — their source isn't +/// inspectable. +pub fn render_auth_help_section(bindings: &[(String, SchemeBinding)]) -> Option { + if bindings.is_empty() { + return None; + } + let max_name = bindings + .iter() + .map(|(n, _)| n.len()) + .max() + .unwrap_or(0) + .max(8); + + let mut out = String::from("Authentication:\n"); + for (name, binding) in bindings { + let sources = describe_binding_sources(binding); + let _ = std::fmt::Write::write_fmt( + &mut out, + format_args!(" {name: String { + match binding { + SchemeBinding::Token(src) => describe_credential_source(src), + SchemeBinding::Basic { username, password } => { + format!( + "basic auth · username: {} · password: {}", + describe_credential_source(username), + describe_credential_source(password), + ) + } + SchemeBinding::Custom(_) => "custom auth provider".to_string(), + } +} + +fn describe_credential_source(src: &AuthCredentialSource) -> String { + match src { + AuthCredentialSource::Env(name) => format!("{name} env var"), + AuthCredentialSource::Cli(arg) => format!("--{arg} flag"), + AuthCredentialSource::File(path) => format!("{} file", path.display()), + AuthCredentialSource::Literal(_) => "built-in literal".to_string(), + AuthCredentialSource::Closure(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" / "), + AuthCredentialSource::Missing => "(unbound)".to_string(), + } +} + +/// Walk every binding in `bindings` and collect the union of CLI arg +/// names they reference. Deduplicated while preserving first-seen order. +pub fn collect_binding_cli_args(bindings: &[(String, SchemeBinding)]) -> Vec { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut out: Vec = Vec::new(); + for (_, b) in bindings { + for arg in b.cli_args() { + if seen.insert(arg.to_string()) { + out.push(arg.to_string()); + } + } + } + out +} + +/// Finalize every binding against `matches`. Returns a new `Vec`; the +/// originals are consumed. +pub fn finalize_bindings( + bindings: Vec<(String, SchemeBinding)>, + matches: &Arc, +) -> Vec<(String, SchemeBinding)> { + bindings + .into_iter() + .map(|(name, b)| (name, b.finalize(matches))) + .collect() +} + +/// Lower a single binding to a concrete provider, given the spec scheme +/// declaration that names it (or `None` if the binding references a scheme +/// not declared in `components.securitySchemes`). +/// +/// Undeclared schemes (`declared == None`) default to bearer for token +/// bindings and basic for two-value bindings — sensible defaults for +/// callers who don't have a spec to lean on (e.g., GraphQL CLIs). +/// +/// When a binding shape doesn't match its declared scheme (e.g., a Token +/// bound to `HttpBasic`), the binding is dropped with a `tracing::warn!` +/// so the misconfiguration shows up in the structured logs rather than +/// silently sending requests with no auth. +fn provider_for_binding( + scheme_name: &str, + binding: &SchemeBinding, + declared: Option<&crate::openapi::discovery::SecurityScheme>, +) -> Option { + use crate::openapi::discovery::SecurityScheme as S; + match binding { + SchemeBinding::Custom(p) => Some(p.clone()), + SchemeBinding::Token(source) => match declared { + // Bearer/OAuth2 → standard Authorization: Bearer . + // Undeclared schemes default to bearer (legacy parity). + Some(S::HttpBearer) | Some(S::OAuth2) | None => Some(Arc::new( + BearerAuthProvider::new(scheme_name, source.clone()), + )), + Some(S::ApiKeyHeader { name }) => Some(Arc::new(HeaderAuthProvider::new( + scheme_name, + name, + source.clone(), + false, + ))), + Some(S::ApiKeyQuery { .. }) => { + tracing::warn!( + scheme = scheme_name, + "auth_scheme: apiKey-in-query schemes are not yet supported; binding ignored", + ); + None + } + Some(S::HttpBasic) => { + tracing::warn!( + scheme = scheme_name, + "auth_scheme: scheme is HTTP Basic but a single-value Token binding was supplied; \ + use auth_basic_scheme instead", + ); + None + } + Some(S::Other(kind)) => { + tracing::warn!( + scheme = scheme_name, + kind = kind, + "auth_scheme: unsupported scheme type; bind via auth_provider with a custom \ + provider instead", + ); + None + } + }, + SchemeBinding::Basic { username, password } => match declared { + Some(S::HttpBasic) | None => Some(Arc::new(BasicAuthProvider::new( + scheme_name, + username.clone(), + password.clone(), + ))), + _ => { + tracing::warn!( + scheme = scheme_name, + "auth_basic_scheme: scheme is not HTTP Basic; binding ignored", + ); + None + } + }, + } +} + +/// Walk a `RestDescription` and decide whether any operation declares +/// per-endpoint security requirements. Used to choose between +/// `AnyAuthProvider` (no spec-level routing needed) and +/// `RoutingAuthProvider` (some endpoints require specific schemes). +fn doc_has_per_endpoint_security(doc: &crate::openapi::discovery::RestDescription) -> bool { + fn walk(res: &crate::openapi::discovery::RestResource) -> bool { + if res + .methods + .values() + .any(|m| m.security_requirements.is_some()) + { + return true; + } + res.resources.values().any(walk) + } + doc.resources.values().any(walk) +} + +/// Protocol-agnostic provider construction. Used directly by GraphQL +/// (which has no spec-declared schemes and no per-endpoint metadata) and +/// indirectly by [`build_provider_from_doc`] for OpenAPI. +/// +/// Equivalent to [`build_provider_with_strategy`] called with +/// [`AuthStrategy::Auto`]: the strategy is derived from +/// `has_per_endpoint_security`. Use [`build_provider_with_strategy`] +/// directly if your generator wants explicit control (e.g., the all-auth +/// case the spec doesn't express). +pub fn build_provider_from_bindings( + bindings: &[(String, SchemeBinding)], + security_schemes: &HashMap, + has_per_endpoint_security: bool, +) -> DynAuthProvider { + build_provider_with_strategy( + bindings, + security_schemes, + AuthStrategy::Auto, + has_per_endpoint_security, + ) +} + +/// Strategy-aware provider construction. The fully general factory. +/// +/// Construction outline: +/// 1. Each binding is lowered to a concrete provider, using `security_schemes` +/// (if non-empty) to pick between Bearer / Header / Basic. +/// 2. Bindings are deduplicated by scheme name — last registration wins for +/// both the routing map and the AnyAuth fallback list, so the two views +/// can never disagree. +/// 3. Insertion order is preserved across the dedup so the `Any` and `All` +/// strategies see schemes in registration order. +/// 4. The `strategy` chooses how the lowered providers compose: +/// - `Auto` → `Routing` if `has_per_endpoint_security`, else `Any`. +/// - `Any` → `AnyAuthProvider`. First with credentials applies. +/// - `All` → `AllAuthProvider`. Every scheme applies, every request. +/// - `Routing` → `RoutingAuthProvider` with `AnyAuthProvider` as default. +/// 5. With no bindings at all, returns a [`NoAuthProvider`] sentinel — +/// independent of `strategy`. `All` / `Routing` with zero bindings would +/// otherwise produce a degenerate composite (an empty `AllAuthProvider` +/// that vacuously claims credentials, or a `RoutingAuthProvider` whose +/// only contribution is its default fallback). Collapsing to +/// `NoAuthProvider` keeps the unauthenticated-CLI case unambiguous. +pub fn build_provider_with_strategy( + bindings: &[(String, SchemeBinding)], + security_schemes: &HashMap, + strategy: AuthStrategy, + has_per_endpoint_security: bool, +) -> DynAuthProvider { + if bindings.is_empty() { + return Arc::new(NoAuthProvider); + } + + let mut by_name: HashMap = HashMap::new(); + let mut order: Vec = Vec::new(); + for (name, binding) in bindings { + let declared = security_schemes.get(name); + // Surface typos: if the spec declared *some* schemes but this + // binding's name isn't among them, the binding will silently never + // route — no operation's `security:` block can match a name that + // isn't in the registry. Don't warn when there are no declared + // schemes (that's legacy-style usage with no spec security). + if declared.is_none() && !security_schemes.is_empty() { + let declared_names: Vec<&str> = + security_schemes.keys().map(String::as_str).collect(); + tracing::warn!( + scheme = name.as_str(), + declared = ?declared_names, + "auth scheme name is not declared in components.securitySchemes; \ + check for typos — operations referencing a different name won't \ + receive this credential", + ); + } + let Some(provider) = provider_for_binding(name, binding, declared) else { + continue; + }; + if !by_name.contains_key(name) { + order.push(name.clone()); + } + by_name.insert(name.clone(), provider); + } + + let ordered: Vec = order.iter().map(|n| by_name[n].clone()).collect(); + + let resolved = match strategy { + AuthStrategy::Auto => { + if has_per_endpoint_security { + AuthStrategy::Routing + } else { + AuthStrategy::Any + } + } + explicit => explicit, + }; + + match resolved { + AuthStrategy::Auto => unreachable!("Auto resolved above"), + AuthStrategy::Any => Arc::new(AnyAuthProvider::new(ordered)), + AuthStrategy::All => Arc::new(AllAuthProvider::new(ordered)), + AuthStrategy::Routing => { + // The default for unspecified endpoints is still AnyAuth over + // all schemes — preserves the "use whatever works" fallback + // for operations the spec didn't pin. + let any: DynAuthProvider = Arc::new(AnyAuthProvider::new(ordered)); + Arc::new(RoutingAuthProvider::new(by_name).with_default(any)) + } + } +} + +/// OpenAPI-flavored convenience: pulls `security_schemes` and the +/// per-endpoint flag out of a parsed [`RestDescription`][rd]. +/// +/// [rd]: crate::openapi::discovery::RestDescription +pub fn build_provider_from_doc( + doc: &crate::openapi::discovery::RestDescription, + bindings: &[(String, SchemeBinding)], +) -> DynAuthProvider { + build_provider_from_bindings( + bindings, + &doc.security_schemes, + doc_has_per_endpoint_security(doc), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::provider::EndpointAuthMetadata; + use crate::auth::test_helpers::{auth_header, header, req}; + + fn doc_with_schemes( + schemes: &[(&str, crate::openapi::discovery::SecurityScheme)], + ) -> crate::openapi::discovery::RestDescription { + let mut d = crate::openapi::discovery::RestDescription::default(); + for (name, scheme) in schemes { + d.security_schemes + .insert((*name).to_string(), scheme.clone()); + } + d + } + + fn doc_with_method_requirement( + schemes: &[(&str, crate::openapi::discovery::SecurityScheme)], + requirement: HashMap>, + ) -> crate::openapi::discovery::RestDescription { + let mut d = doc_with_schemes(schemes); + let method = crate::openapi::discovery::RestMethod { + security_requirements: Some(vec![requirement]), + ..Default::default() + }; + let mut resource = crate::openapi::discovery::RestResource::default(); + resource.methods.insert("op".to_string(), method); + d.resources.insert("group".to_string(), resource); + d + } + + #[test] + fn no_bindings_returns_noop() { + let doc = crate::openapi::discovery::RestDescription::default(); + let p = build_provider_from_doc(&doc, &[]); + assert_eq!(p.name(), "none"); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn bearer_scheme_routes_to_bearer_provider() { + let doc = doc_with_schemes(&[( + "bearerAuth", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )]); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + assert_eq!(p.name(), "any"); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn apikey_header_uses_declared_header_name() { + let doc = doc_with_schemes(&[( + "apiKey", + crate::openapi::discovery::SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + )]); + let bindings = vec![( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("k")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("k")); + } + + #[tokio::test] + async fn basic_scheme_routes_to_basic_provider() { + let doc = doc_with_schemes(&[( + "basic", + crate::openapi::discovery::SecurityScheme::HttpBasic, + )]); + let bindings = vec![( + "basic".to_string(), + SchemeBinding::Basic { + username: AuthCredentialSource::literal("alice"), + password: AuthCredentialSource::literal("hunter2"), + }, + )]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!( + auth_header(r).as_deref(), + Some("Basic YWxpY2U6aHVudGVyMg=="), + ); + } + + #[test] + fn token_binding_for_basic_scheme_is_skipped() { + // Token form can't satisfy HttpBasic (which needs two values). + // The binding should be silently dropped — provider has no creds. + let doc = doc_with_schemes(&[( + "basic", + crate::openapi::discovery::SecurityScheme::HttpBasic, + )]); + let bindings = vec![( + "basic".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("oops")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn uses_routing_when_doc_has_per_endpoint_security() { + let mut req_map = HashMap::new(); + req_map.insert("apiKey".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[ + ( + "bearerAuth", + crate::openapi::discovery::SecurityScheme::HttpBearer, + ), + ( + "apiKey", + crate::openapi::discovery::SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + ), + ], + req_map.clone(), + ); + let bindings = vec![ + ( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + ), + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("k")), + ), + ]; + let p = build_provider_from_doc(&doc, &bindings); + assert_eq!(p.name(), "routing"); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req_map]); + let r = p.apply(req(), &endpoint).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + assert!(built.headers().get("authorization").is_none()); + } + + #[tokio::test] + async fn routing_falls_back_to_any_for_unspecified_endpoint() { + let mut req_map = HashMap::new(); + req_map.insert("apiKey".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "bearerAuth", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )], + req_map, + ); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn duplicate_binding_uses_last_write_consistently() { + // Two bindings to the same scheme name. The user almost certainly + // didn't mean it, but if it happens, both the AnyAuth fallback and + // the RoutingAuth map must agree on which provider wins. + let mut req_map = HashMap::new(); + req_map.insert("apiKey".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "apiKey", + crate::openapi::discovery::SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + )], + req_map.clone(), + ); + let bindings = vec![ + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("first")), + ), + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("second")), + ), + ]; + let p = build_provider_from_doc(&doc, &bindings); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req_map]); + let r = p.apply(req(), &endpoint).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("second")); + let r2 = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r2, "x-api-key").as_deref(), Some("second")); + } + + #[tokio::test] + async fn from_bindings_works_without_doc() { + // GraphQL path: no security_schemes registry, no per-endpoint + // metadata, but the same builder API. Should still produce a + // working AnyAuthProvider. + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("g")), + )]; + let p = build_provider_from_bindings(&bindings, &HashMap::new(), false); + assert_eq!(p.name(), "any"); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer g")); + } + + #[tokio::test] + async fn strategy_all_applies_every_scheme_unconditionally() { + // Generator knows the API requires bearer AND apiKey on every + // request. Spec might not express this; the strategy override + // does. + let bindings = vec![ + ( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + ), + ( + "apiKey".to_string(), + SchemeBinding::Custom(crate::auth::test_helpers::api_key( + "apiKey", + "X-Api-Key", + "k", + )), + ), + ]; + let p = build_provider_with_strategy( + &bindings, + &HashMap::new(), + AuthStrategy::All, + false, + ); + assert_eq!(p.name(), "all"); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + } + + #[test] + fn strategy_any_overrides_spec_routing() { + // Spec has per-endpoint security (would auto-pick Routing), but + // the generator forces Any anyway. Verifies the override actually + // wins. + let mut req_map = HashMap::new(); + req_map.insert("bearer".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "bearer", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )], + req_map, + ); + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = build_provider_with_strategy( + &bindings, + &doc.security_schemes, + AuthStrategy::Any, + true, // doc has per-endpoint security, but Any wins + ); + assert_eq!(p.name(), "any"); + } + + #[test] + fn strategy_routing_used_even_without_per_endpoint_security() { + // Generator wants routing semantics regardless of what the spec + // says. Falls back to AnyAuthProvider default for any op without + // requirements. + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = build_provider_with_strategy( + &bindings, + &HashMap::new(), + AuthStrategy::Routing, + false, // no per-endpoint security in the spec + ); + assert_eq!(p.name(), "routing"); + } + + #[test] + fn strategy_auto_picks_routing_when_spec_has_per_endpoint_security() { + let mut req_map = HashMap::new(); + req_map.insert("bearer".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "bearer", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )], + req_map, + ); + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = + build_provider_with_strategy(&bindings, &doc.security_schemes, AuthStrategy::Auto, true); + assert_eq!(p.name(), "routing"); + } + + #[test] + fn strategy_routing_with_zero_bindings_returns_no_auth() { + // Explicit Routing strategy + no bindings collapses to NoAuthProvider. + // Confirms the early-return at the top of build_provider_with_strategy + // applies regardless of `strategy` — a Routing wrapper around zero + // schemes would have only its (also empty) default to fall back on, + // which isn't a useful state to expose. + let p = build_provider_with_strategy( + &[], + &HashMap::new(), + AuthStrategy::Routing, + true, + ); + assert_eq!(p.name(), "none"); + assert!(!p.has_credentials()); + } + + #[test] + fn strategy_all_with_zero_bindings_returns_no_auth() { + // Same contract for All. An empty AllAuthProvider would vacuously + // claim no credentials anyway, but collapsing to NoAuthProvider + // keeps the unauthenticated case uniform across strategies. + let p = build_provider_with_strategy( + &[], + &HashMap::new(), + AuthStrategy::All, + false, + ); + assert_eq!(p.name(), "none"); + assert!(!p.has_credentials()); + } + + #[test] + fn strategy_auto_picks_any_when_no_per_endpoint_security() { + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = build_provider_with_strategy( + &bindings, + &HashMap::new(), + AuthStrategy::Auto, + false, + ); + assert_eq!(p.name(), "any"); + } + + // -------- render_auth_help_section -------- + + #[test] + fn render_auth_help_section_none_for_empty_bindings() { + assert!(render_auth_help_section(&[]).is_none()); + } + + #[test] + fn render_auth_help_section_describes_env_var() { + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::from_env("API_TOKEN")), + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("Authentication:")); + assert!(out.contains("bearerAuth")); + assert!(out.contains("API_TOKEN env var")); + } + + #[test] + fn render_auth_help_section_describes_chain() { + let bindings = vec![( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::any([ + AuthCredentialSource::cli("api-key"), + AuthCredentialSource::from_env("API_KEY"), + AuthCredentialSource::file("~/.api/key"), + ])), + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("--api-key flag")); + assert!(out.contains("API_KEY env var")); + assert!(out.contains("~/.api/key file")); + assert!(out.contains(" / ")); + } + + #[test] + fn render_auth_help_section_describes_basic_pair() { + let bindings = vec![( + "basic".to_string(), + SchemeBinding::Basic { + username: AuthCredentialSource::from_env("API_USER"), + password: AuthCredentialSource::from_env("API_PASS"), + }, + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("basic")); + assert!(out.contains("username")); + assert!(out.contains("password")); + assert!(out.contains("API_USER env var")); + assert!(out.contains("API_PASS env var")); + } + + #[test] + fn render_auth_help_section_marks_custom_provider_opaque() { + let bindings = vec![( + "x".to_string(), + SchemeBinding::Custom(crate::auth::test_helpers::bearer("x", "tok")), + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("custom auth provider")); + } + + #[tokio::test] + async fn custom_binding_used_as_is() { + let custom: DynAuthProvider = Arc::new(HeaderAuthProvider::new( + "custom", + "X-Custom", + AuthCredentialSource::literal("c"), + false, + )); + let doc = crate::openapi::discovery::RestDescription::default(); + let bindings = vec![("custom".to_string(), SchemeBinding::Custom(custom))]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-custom").as_deref(), Some("c")); + } + +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/compose.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/compose.rs new file mode 100644 index 000000000000..dd06d2365493 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/compose.rs @@ -0,0 +1,589 @@ +//! Composition wrappers: [`AnyAuthProvider`] (OR semantics) and +//! [`RoutingAuthProvider`] (per-endpoint dispatch via the operation's +//! `security_requirements`). + +use std::collections::HashMap; + +use crate::auth::provider::{AuthProvider, DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// AnyAuthProvider — OR semantics. +// --------------------------------------------------------------------------- + +/// Try each child provider in order. The first one with credentials applies +/// its headers and the wrapper returns. If no child has credentials, the +/// request goes out unauthenticated. +/// +/// Mirrors the TS `AnyAuthProvider`. Used when the CLI declares multiple +/// schemes but the OpenAPI operations don't pin one per endpoint. +#[derive(Debug, Clone)] +pub struct AnyAuthProvider { + name: String, + providers: Vec, +} + +impl AnyAuthProvider { + pub fn new(providers: Vec) -> Self { + Self { + name: "any".to_string(), + providers, + } + } +} + +impl AuthProvider for AnyAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.providers.iter().any(|p| p.has_credentials()) + } + + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + self.providers + .iter() + .any(|p| p.has_credentials_for(endpoint)) + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + // Endpoint-aware filter: lets nested `RoutingAuthProvider` children + // tell us they can't satisfy *this* endpoint even though they have + // credentials for some scheme. Leaf providers (Bearer/Basic/Header) + // ignore the endpoint, so this degenerates to `has_credentials()` + // for them. + for provider in &self.providers { + if provider.has_credentials_for(endpoint) { + return provider.apply(request, endpoint); + } + } + Ok(request) + } +} + +// --------------------------------------------------------------------------- +// AllAuthProvider — AND semantics. Every scheme is applied to every request. +// --------------------------------------------------------------------------- + +/// Apply *every* child provider's headers to the request, in registration +/// order. The "all auth" strategy: when an API requires multiple schemes +/// simultaneously on every operation (e.g., `Authorization: Bearer X` AND +/// `X-Api-Key: Y`), and the spec doesn't express that via per-operation +/// security blocks. +/// +/// `has_credentials()` is `true` only when *all* children have credentials — +/// the request can't be satisfied otherwise. If a child fails to apply +/// (e.g., malformed token bytes), the error short-circuits. +#[derive(Debug, Clone)] +pub struct AllAuthProvider { + name: String, + providers: Vec, +} + +impl AllAuthProvider { + pub fn new(providers: Vec) -> Self { + Self { + name: "all".to_string(), + providers, + } + } +} + +impl AuthProvider for AllAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + // All-auth means every scheme must contribute. If any is missing, + // the request can't be authenticated as the API requires. + !self.providers.is_empty() && self.providers.iter().all(|p| p.has_credentials()) + } + + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + !self.providers.is_empty() + && self + .providers + .iter() + .all(|p| p.has_credentials_for(endpoint)) + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + // Short-circuit when the requirement can't be fully satisfied. The + // all-auth contract is "every scheme contributes"; sending a partial + // request with only some headers attached would let the request hit + // the wire half-authed and leak whichever bound credentials we do + // have. The friendly-error path catches this on the response side, + // but pre-emptively dropping the headers keeps stray tokens off the + // wire too. + if !self.has_credentials_for(endpoint) { + return Ok(request); + } + let mut req = request; + for provider in &self.providers { + req = provider.apply(req, endpoint)?; + } + Ok(req) + } +} + +// --------------------------------------------------------------------------- +// RoutingAuthProvider — per-endpoint security map. +// --------------------------------------------------------------------------- + +/// Dispatch by the endpoint's security requirements. The OpenAPI `security` +/// field is an OR of ANDs: `[{schemeA: []}, {schemeB: [], schemeC: []}]` +/// means "schemeA alone, OR (schemeB AND schemeC)". +/// +/// At call time: +/// 1. If the endpoint has no requirements, the wrapper falls through to its +/// `default` policy (typically an [`AnyAuthProvider`]) so unauthenticated +/// operations stay unauthenticated and unlabeled operations still get a +/// sensible default header. +/// 2. Otherwise, find the first requirement whose every scheme has a +/// registered provider with credentials, and apply each provider in turn +/// (their headers compose). +/// 3. If no requirement is satisfiable, return the request unchanged. The +/// server will respond 401/403 and `handle_error_response` +/// will surface a helpful "no credentials configured" message. +#[derive(Debug, Clone)] +pub struct RoutingAuthProvider { + name: String, + schemes: HashMap, + /// Fallback for endpoints with no `security` declared. Typically an + /// [`AnyAuthProvider`] over all configured schemes. + default: Option, +} + +impl RoutingAuthProvider { + pub fn new(schemes: HashMap) -> Self { + Self { + name: "routing".to_string(), + schemes, + default: None, + } + } + + pub fn with_default(mut self, default: DynAuthProvider) -> Self { + self.default = Some(default); + self + } +} + +impl AuthProvider for RoutingAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.schemes.values().any(|p| p.has_credentials()) + || self.default.as_ref().is_some_and(|p| p.has_credentials()) + } + + /// Endpoint-aware credential check. + /// + /// - **No requirements declared**: defer to the wrapper's `default` + /// (typically an `AnyAuthProvider`), which decides based on its own + /// children. If there's no default, fall back to `has_credentials()` + /// over our schemes — that's the closest we can get. + /// - **Explicit anonymous (`security: []`)**: the endpoint doesn't need + /// auth, so report `true` to suppress the friendly "no creds" message + /// on a 401 — that response would be a server-side mismatch, not a + /// user config issue. + /// - **Concrete requirements**: report whether any requirement's + /// schemes are all bound *and* hold credentials — same predicate + /// `apply` uses to find a satisfiable requirement. If yes, we + /// attached headers; if no, the 401 is the user's missing-creds + /// problem and the friendly error fires. + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + match &endpoint.security_requirements { + None => match &self.default { + Some(d) => d.has_credentials_for(endpoint), + None => self.has_credentials(), + }, + Some(reqs) if reqs.is_empty() => true, + Some(reqs) => reqs.iter().any(|req| { + req.keys().all(|name| { + self.schemes + .get(name) + .is_some_and(|p| p.has_credentials()) + }) + }), + } + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + let requirements = match &endpoint.security_requirements { + // Operation didn't pin a policy: defer to the default. + None => { + return match &self.default { + Some(d) => d.apply(request, endpoint), + None => Ok(request), + }; + } + // `security: []` — explicit anonymous, attach nothing. + Some(reqs) if reqs.is_empty() => return Ok(request), + Some(reqs) => reqs, + }; + + let satisfiable = requirements.iter().find(|req| { + req.keys().all(|name| { + self.schemes + .get(name) + .is_some_and(|p| p.has_credentials()) + }) + }); + + let Some(requirement) = satisfiable else { + // No declared requirement is satisfiable. Diverges from the TS + // generator (which throws): we let the request go out unauthed + // so the server's 401/403 + `handle_error_response` + // can surface a friendly "no credentials configured" message. + return Ok(request); + }; + + let mut req = request; + // Sort the requirement's scheme names so multi-scheme requirements + // apply in a stable order regardless of `HashMap` iteration. Each + // provider sets a distinct header so order doesn't affect the wire + // payload, but reproducibility matters for tracing and snapshot + // tests. + let mut scheme_names: Vec<&String> = requirement.keys().collect(); + scheme_names.sort(); + for scheme_name in scheme_names { + // Safe: `satisfiable` filtered to requirements where every key + // has a registered provider. + let provider = &self.schemes[scheme_name]; + req = provider.apply(req, endpoint)?; + } + Ok(req) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use crate::auth::credential::AuthCredentialSource; + use crate::auth::schemes::{BearerAuthProvider, HeaderAuthProvider}; + use crate::auth::test_helpers::{api_key, auth_header, bearer, header, req}; + + // -------- AnyAuthProvider -------- + + #[tokio::test] + async fn any_auth_picks_first_with_credentials() { + let a: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "a", + AuthCredentialSource::Missing, + )); + let b: DynAuthProvider = api_key("b", "X-Api-Key", "k"); + let any = AnyAuthProvider::new(vec![a, b]); + assert!(any.has_credentials()); + let r = any.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("k")); + } + + #[tokio::test] + async fn any_auth_skips_routing_child_that_cant_satisfy_endpoint() { + // Nested-composition guard: an `AnyAuthProvider` whose first child + // is a `RoutingAuthProvider` that *has some credentials* but can't + // satisfy this specific endpoint must fall through to the next + // child rather than calling apply on the routing child. + // + // Without the endpoint-aware filter, the first child's + // `has_credentials()` returns true and `apply` short-circuits — even + // though that child would attach nothing to the request. + let mut routing_schemes: HashMap = HashMap::new(); + routing_schemes.insert("apiKey".to_string(), api_key("apiKey", "X-Api-Key", "k")); + let routing_child: DynAuthProvider = std::sync::Arc::new( + RoutingAuthProvider::new(routing_schemes), + ); + let bearer_child: DynAuthProvider = bearer("bearer", "tok"); + let any = AnyAuthProvider::new(vec![routing_child, bearer_child]); + + // Endpoint demands `bearer`; routing child only has `apiKey`. + let mut requirement = HashMap::new(); + requirement.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![requirement]); + + let r = any.apply(req(), &endpoint).unwrap(); + // Bearer should have been attached by the second child. + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn any_auth_no_credentials_is_passthrough() { + let any = AnyAuthProvider::new(vec![Arc::new(BearerAuthProvider::new( + "x", + AuthCredentialSource::Missing, + ))]); + assert!(!any.has_credentials()); + let r = any.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r), None); + } + + // -------- AllAuthProvider -------- + + #[tokio::test] + async fn all_auth_applies_every_provider() { + let a: DynAuthProvider = bearer("a", "tok"); + let b: DynAuthProvider = api_key("b", "X-Api-Key", "k"); + let all = AllAuthProvider::new(vec![a, b]); + assert!(all.has_credentials()); + let r = all.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + } + + #[test] + fn all_auth_has_credentials_requires_every_child() { + let a: DynAuthProvider = bearer("a", "tok"); + let b: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "b", + AuthCredentialSource::Missing, + )); + let all = AllAuthProvider::new(vec![a, b]); + // One missing → all auth can't be satisfied. + assert!(!all.has_credentials()); + } + + #[test] + fn all_auth_empty_provider_list_is_no_credentials() { + // Vacuous truth would say "all of zero providers have creds = true", + // but for the all-auth strategy that's misleading: no providers + // means no auth gets attached, which isn't what the user asked for. + let all = AllAuthProvider::new(Vec::new()); + assert!(!all.has_credentials()); + } + + #[tokio::test] + async fn all_auth_short_circuits_on_provider_error() { + // Bearer with a token containing CTL chars errors in apply. + let bad: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "bad", + AuthCredentialSource::literal("bad\ntoken"), + )); + let good: DynAuthProvider = api_key("good", "X-Api-Key", "k"); + // Order matters: bad first → error before good ever runs. + let all = AllAuthProvider::new(vec![bad, good]); + let err = all + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap_err(); + assert!(matches!(err, CliError::Auth(_))); + } + + // -------- RoutingAuthProvider -------- + + fn routing_setup() -> RoutingAuthProvider { + let mut schemes: HashMap = HashMap::new(); + schemes.insert("bearer".to_string(), bearer("bearer", "tok")); + schemes.insert("apiKey".to_string(), api_key("apiKey", "X-Api-Key", "k")); + RoutingAuthProvider::new(schemes) + } + + #[tokio::test] + async fn routing_unspecified_no_default_is_passthrough() { + let r = routing_setup(); + assert!(r.has_credentials()); + let out = r + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(out), None); + } + + #[tokio::test] + async fn routing_explicit_anonymous_skips_auth_even_with_default() { + // `security: []` on the operation means the endpoint is explicitly + // unauthenticated. Even with a default provider that would happily + // attach a bearer header, the routing wrapper must respect the + // operation's opt-out. + let routing = RoutingAuthProvider::new(HashMap::new()) + .with_default(bearer("bearer", "tok")); + let out = routing + .apply(req(), &EndpointAuthMetadata::explicit_anonymous()) + .unwrap(); + assert_eq!(auth_header(out), None); + } + + #[tokio::test] + async fn routing_picks_satisfiable_requirement() { + let routing = routing_setup(); + let mut req_a = HashMap::new(); + req_a.insert("apiKey".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req_a]); + let out = routing.apply(req(), &endpoint).unwrap(); + assert_eq!(header(out, "x-api-key").as_deref(), Some("k")); + } + + #[tokio::test] + async fn routing_falls_back_to_or_alternative() { + let routing = routing_setup(); + let mut req1 = HashMap::new(); + req1.insert("nonexistent".to_string(), Vec::::new()); + let mut req2 = HashMap::new(); + req2.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req1, req2]); + let out = routing.apply(req(), &endpoint).unwrap(); + assert_eq!(auth_header(out).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn routing_combines_anded_schemes_in_one_requirement() { + let routing = routing_setup(); + let mut requirement = HashMap::new(); + requirement.insert("bearer".to_string(), Vec::::new()); + requirement.insert("apiKey".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![requirement]); + let out = routing.apply(req(), &endpoint).unwrap(); + let built = out.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + } + + #[tokio::test] + async fn routing_uses_default_when_endpoint_has_no_requirements() { + let routing = RoutingAuthProvider::new(HashMap::new()) + .with_default(bearer("bearer", "tok")); + let out = routing + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(out).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn routing_no_satisfiable_requirement_is_passthrough() { + let mut schemes: HashMap = HashMap::new(); + schemes.insert( + "bearer".to_string(), + Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )), + ); + let routing = RoutingAuthProvider::new(schemes); + let mut requirement = HashMap::new(); + requirement.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![requirement]); + let out = routing.apply(req(), &endpoint).unwrap(); + assert_eq!(auth_header(out), None); + } + + // -------- has_credentials_for(endpoint) -------- + + #[test] + fn routing_has_credentials_for_unspecified_with_no_default_uses_general_check() { + let r = routing_setup(); + // No default → falls back to has_credentials() over schemes. + assert!(r.has_credentials_for(&EndpointAuthMetadata::unspecified())); + } + + #[test] + fn routing_has_credentials_for_explicit_anonymous_is_true() { + // `security: []` means "no creds needed" — report true so a 401 + // doesn't trigger the friendly "no creds" message (the response + // would be a server-side mismatch, not a user config issue). + let r = routing_setup(); + assert!(r.has_credentials_for(&EndpointAuthMetadata::explicit_anonymous())); + } + + #[test] + fn routing_has_credentials_for_satisfiable_requirement_is_true() { + let r = routing_setup(); + let mut req = HashMap::new(); + req.insert("apiKey".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req]); + assert!(r.has_credentials_for(&endpoint)); + } + + #[test] + fn routing_has_credentials_for_unsatisfiable_requirement_is_false() { + // The endpoint requires `bearer` but bearer's source is Missing. + let mut schemes: HashMap = HashMap::new(); + schemes.insert( + "bearer".to_string(), + Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )), + ); + // Also bind apiKey with creds — proving has_credentials_for is + // *endpoint-aware*, not just "any scheme has creds". + schemes.insert("apiKey".to_string(), api_key("apiKey", "X-Api-Key", "k")); + let routing = RoutingAuthProvider::new(schemes); + let mut req = HashMap::new(); + req.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req]); + // Even though `apiKey` has creds, the endpoint demands `bearer` — + // and bearer has none. So the friendly error path should fire. + assert!(!routing.has_credentials_for(&endpoint)); + // But the coarse `has_credentials()` returns true. This is the + // delta the new method exists to fix. + assert!(routing.has_credentials()); + } + + #[test] + fn routing_has_credentials_for_unspecified_delegates_to_default() { + // Default present + has creds → endpoint-aware check inherits. + let routing = RoutingAuthProvider::new(HashMap::new()) + .with_default(bearer("bearer", "tok")); + assert!(routing.has_credentials_for(&EndpointAuthMetadata::unspecified())); + } + + #[test] + fn routing_has_credentials_for_unspecified_propagates_default_false() { + // Default present but its provider has no creds → we should + // honestly report no creds, not just "yes, a default exists". + // Pins that the delegation actually consults the default's + // predicate rather than treating "is there a default" as a yes/no. + let empty_default: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )); + let routing = RoutingAuthProvider::new(HashMap::new()).with_default(empty_default); + assert!(!routing.has_credentials_for(&EndpointAuthMetadata::unspecified())); + } + + // Sanity-check that routing_setup produces a HeaderAuthProvider with the + // expected name when looked up by scheme key — guards against an + // accidental shape change in the test helper. + #[test] + fn routing_setup_registers_provider_named_apikey() { + let r = routing_setup(); + assert_eq!(r.schemes["apiKey"].name(), "apiKey"); + assert_eq!(r.schemes["bearer"].name(), "bearer"); + // Silence dead-code lint on HeaderAuthProvider import path. + let _: &dyn AuthProvider = &HeaderAuthProvider::new( + "x", + "Y", + AuthCredentialSource::Missing, + false, + ); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/credential.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/credential.rs new file mode 100644 index 000000000000..68734684e069 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/credential.rs @@ -0,0 +1,549 @@ +//! `AuthCredentialSource` — the lazy-supplier model for credential values. +//! +//! Mirrors the TypeScript SDK's `Supplier` and grows it into a full +//! resolution graph. Each binding holds a description of *where* its value +//! comes from — env var, CLI flag, file, literal, fallback chain, or +//! arbitrary closure — without coupling that to the auth provider that +//! consumes the resolved string. +//! +//! Resolution happens at request time so env-var changes between +//! invocations Just Work, files re-read on every call, and fallback chains +//! can mix any of the source kinds. +//! +//! # CLI flag wiring +//! +//! [`AuthCredentialSource::Cli`] holds the *name* of a clap arg — the SDK's +//! `CliApp::run_async` walks every registered binding before parsing, +//! auto-registers a global `--` flag for each `Cli` variant, and then +//! finalizes the bindings post-parse so that resolution reads from the +//! captured matches. None of this is visible to the binding's author — +//! they just write `AuthCredentialSource::cli("api-token")` and the flag +//! shows up. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use secrecy::SecretString; + +type CredentialClosure = Arc Option + Send + Sync>; + +/// How an auth credential's value is resolved at request time. +#[derive(Clone)] +pub enum AuthCredentialSource { + /// Read from a process environment variable. Surrounding whitespace is + /// trimmed; returns `None` if unset, empty, or whitespace-only — + /// matching the trimming behaviour of [`File`](Self::File) so chained + /// sources behave the same regardless of which one supplies the value. + Env(String), + /// Read from a clap CLI arg. The string is the arg's *name* (clap's + /// internal id), not the `--flag` form — `cli("api-token")` corresponds + /// to a `--api-token` flag. Leading `--` / `-` are stripped for + /// convenience, so `cli("--api-token")` works too. + /// + /// Until the binding is finalized via [`finalize`](Self::finalize) (i.e. + /// before clap parses), this variant always resolves to `None` — + /// CliApp does the finalization automatically before any request runs. + Cli(String), + /// Read the contents of a file. `~` and `~/` are expanded to the + /// process's `$HOME`. Trailing whitespace is trimmed; a missing file + /// or empty content resolves to `None`. + File(PathBuf), + /// A literal value embedded at build time. + Literal(String), + /// Fallback chain. Each child is tried in order; the first to return + /// `Some` wins. Empty results count as "missing" — useful for + /// "CLI flag, then env var, then file" patterns. + Chain(Vec), + /// A user-supplied closure invoked on every request. The escape hatch + /// for any source the built-in variants don't cover (token refresh, + /// shell-out, OS keychain, etc.). + Closure(CredentialClosure), + /// No source bound. The provider will report itself as unable to + /// satisfy requests. + Missing, +} + +impl AuthCredentialSource { + pub fn from_env(var_name: impl Into) -> Self { + AuthCredentialSource::Env(var_name.into()) + } + + /// Bind to a clap CLI arg. Accepts either `"api-token"` or + /// `"--api-token"` — leading dashes are stripped. + pub fn cli(arg_name: impl Into) -> Self { + let raw = arg_name.into(); + let name = raw.trim_start_matches('-').to_string(); + AuthCredentialSource::Cli(name) + } + + /// Bind to a file path. `~` and `~/` expand against `$HOME`. + pub fn file(path: impl AsRef) -> Self { + AuthCredentialSource::File(path.as_ref().to_path_buf()) + } + + pub fn literal(value: impl Into) -> Self { + AuthCredentialSource::Literal(value.into()) + } + + /// Try each source in order; the first non-empty value wins. + pub fn any(sources: impl IntoIterator) -> Self { + AuthCredentialSource::Chain(sources.into_iter().collect()) + } + + pub fn closure(f: F) -> Self + where + F: Fn() -> Option + Send + Sync + 'static, + { + AuthCredentialSource::Closure(Arc::new(f)) + } + + /// Resolve the value, if available. Empty strings are treated as + /// missing — they would otherwise produce an empty header, which is + /// almost never what a caller intends. + /// + /// Returns a [`SecretString`] so the value can't accidentally leak via + /// `Debug`/`Display`/panic messages. Callers that need the raw `&str` + /// (to build a `HeaderValue`, base64-encode for basic auth, etc.) + /// must opt in explicitly via [`ExposeSecret::expose_secret`]. + pub fn resolve(&self) -> Option { + match self { + AuthCredentialSource::Env(name) => std::env::var(name) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .map(SecretString::from), + AuthCredentialSource::Cli(_) => None, // resolved post-finalize + AuthCredentialSource::File(path) => read_credential_file(path), + AuthCredentialSource::Literal(v) if v.is_empty() => None, + AuthCredentialSource::Literal(v) => Some(SecretString::from(v.clone())), + AuthCredentialSource::Chain(sources) => sources.iter().find_map(|s| s.resolve()), + AuthCredentialSource::Closure(f) => f().filter(|v| !v.is_empty()).map(SecretString::from), + AuthCredentialSource::Missing => None, + } + } + + /// Recursively collect every CLI arg name this source references. + /// CliApp uses this before clap parsing to register the corresponding + /// global `--` flags. + pub fn cli_args(&self) -> Vec<&str> { + let mut out = Vec::new(); + self.collect_cli_args(&mut out); + out + } + + fn collect_cli_args<'a>(&'a self, out: &mut Vec<&'a str>) { + // Enumerate every variant explicitly so adding a future variant + // (especially one that nests sources or carries an arg name) is a + // compile error rather than a silent miss. + match self { + AuthCredentialSource::Cli(name) => out.push(name.as_str()), + AuthCredentialSource::Chain(sources) => { + for s in sources { + s.collect_cli_args(out); + } + } + AuthCredentialSource::Env(_) + | AuthCredentialSource::File(_) + | AuthCredentialSource::Literal(_) + | AuthCredentialSource::Closure(_) + | AuthCredentialSource::Missing => {} + } + } + + /// Replace every `Cli(name)` variant in this source with a `Closure` + /// that reads the matched value out of `matches`. Called by CliApp + /// after clap parses, so that subsequent `resolve()` calls can see the + /// CLI-supplied values. + /// + /// Pass-through for non-`Cli` variants. Recurses into `Chain`. + pub fn finalize(self, matches: &Arc) -> Self { + match self { + AuthCredentialSource::Cli(name) => { + let m = Arc::clone(matches); + AuthCredentialSource::Closure(Arc::new(move || { + m.try_get_one::(&name).ok().flatten().cloned() + })) + } + AuthCredentialSource::Chain(sources) => { + AuthCredentialSource::Chain( + sources.into_iter().map(|s| s.finalize(matches)).collect(), + ) + } + other => other, + } + } +} + +impl std::fmt::Debug for AuthCredentialSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthCredentialSource::Env(name) => write!(f, "Env({name})"), + AuthCredentialSource::Cli(name) => write!(f, "Cli({name})"), + AuthCredentialSource::File(path) => write!(f, "File({})", path.display()), + AuthCredentialSource::Literal(_) => write!(f, "Literal()"), + AuthCredentialSource::Chain(sources) => f.debug_tuple("Chain").field(sources).finish(), + AuthCredentialSource::Closure(_) => write!(f, "Closure"), + AuthCredentialSource::Missing => write!(f, "Missing"), + } + } +} + +/// Read a credential file: expand `~`, trim trailing whitespace, treat +/// empty content / missing files as `None`. Result is wrapped in +/// [`SecretString`] so the file contents can't leak through Debug. +fn read_credential_file(path: &Path) -> Option { + let expanded = expand_home(path); + let raw = std::fs::read_to_string(&expanded).ok()?; + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(SecretString::from(trimmed)) + } +} + +/// Expand a leading `~` or `~/` against the user's home directory. On +/// Unix that's `$HOME`; on Windows we fall back to `%USERPROFILE%` since +/// `$HOME` is typically unset there. Other forms (`~user`, embedded `~`) +/// are left as-is — uncommon for credential paths and surprising to +/// silently rewrite. +fn expand_home(path: &Path) -> PathBuf { + let s = match path.to_str() { + Some(s) => s, + None => return path.to_path_buf(), + }; + if s == "~" { + return home_dir().unwrap_or_else(|| path.to_path_buf()); + } + if let Some(rest) = s.strip_prefix("~/") { + if let Some(home) = home_dir() { + return home.join(rest); + } + } + path.to_path_buf() +} + +/// Cross-platform home directory lookup: `$HOME` first (set on Unix and +/// honored on Windows under WSL/MSYS shells), then `%USERPROFILE%` as the +/// native Windows fallback. +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use secrecy::ExposeSecret; + + /// Test helper: resolve the source and expose the secret so assertions + /// can compare against plain strings. Production code should keep the + /// `SecretString` wrapper as long as possible. + fn resolved(s: &AuthCredentialSource) -> Option { + s.resolve().map(|v| v.expose_secret().to_string()) + } + + // -------- Env -------- + + #[test] + fn literal_resolves() { + assert_eq!( + resolved(&AuthCredentialSource::literal("abc")), + Some("abc".to_string()), + ); + } + + #[test] + fn env_returns_none_when_unset() { + let s = AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_DEFINITELY_UNSET"); + assert_eq!(resolved(&s), None); + } + + #[test] + fn env_treats_empty_as_missing() { + let key = "FERN_CLI_AUTH_TEST_EMPTY"; + std::env::set_var(key, ""); + let s = AuthCredentialSource::from_env(key); + assert_eq!(resolved(&s), None); + std::env::remove_var(key); + } + + #[test] + fn env_treats_whitespace_only_as_missing() { + // Parity with `File` (which trims). A whitespace-only env var would + // otherwise produce a header value of " ", which is almost never + // what the user intended and breaks `Chain` fallthrough. + let key = "FERN_CLI_AUTH_TEST_WHITESPACE"; + std::env::set_var(key, " \t \n"); + let s = AuthCredentialSource::from_env(key); + assert_eq!(resolved(&s), None); + std::env::remove_var(key); + } + + #[test] + fn env_trims_surrounding_whitespace() { + let key = "FERN_CLI_AUTH_TEST_TRIM"; + std::env::set_var(key, " tok \n"); + let s = AuthCredentialSource::from_env(key); + assert_eq!(resolved(&s), Some("tok".to_string())); + std::env::remove_var(key); + } + + // -------- Closure -------- + + #[test] + fn closure_resolves() { + let s = AuthCredentialSource::closure(|| Some("zzz".to_string())); + assert_eq!(resolved(&s), Some("zzz".to_string())); + } + + #[test] + fn closure_returning_none_is_missing() { + let s = AuthCredentialSource::closure(|| None); + assert_eq!(resolved(&s), None); + } + + #[test] + fn closure_returning_empty_string_is_missing() { + let s = AuthCredentialSource::closure(|| Some(String::new())); + assert_eq!(resolved(&s), None); + } + + #[test] + fn missing_resolves_to_none() { + assert_eq!(resolved(&AuthCredentialSource::Missing), None); + } + + // -------- File -------- + + #[test] + fn file_reads_and_trims_contents() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + std::fs::write(&path, " my-token \n").unwrap(); + let s = AuthCredentialSource::file(&path); + assert_eq!(resolved(&s), Some("my-token".to_string())); + } + + #[test] + fn file_missing_resolves_to_none() { + let s = AuthCredentialSource::file("/definitely/not/a/real/path-xyz"); + assert_eq!(resolved(&s), None); + } + + #[test] + fn file_empty_content_is_missing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("empty"); + std::fs::write(&path, " \n\n").unwrap(); + let s = AuthCredentialSource::file(&path); + assert_eq!(resolved(&s), None); + } + + #[test] + fn literal_empty_string_resolves_to_none() { + // Consistency with Env / Closure variants: empty values aren't + // sent as headers. Also makes `Chain([literal(""), env(...)])` + // fall through to the env source as a user would expect. + assert_eq!(resolved(&AuthCredentialSource::literal("")), None); + } + + #[test] + fn chain_with_empty_literal_falls_through() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::literal(""), + AuthCredentialSource::literal("backup"), + ]); + assert_eq!(resolved(&s), Some("backup".to_string())); + } + + #[test] + fn home_dir_falls_back_to_userprofile_when_home_unset() { + // Save/restore both env vars to keep test isolated. + let prev_home = std::env::var_os("HOME"); + let prev_userprofile = std::env::var_os("USERPROFILE"); + + std::env::remove_var("HOME"); + std::env::set_var("USERPROFILE", "/win-home"); + assert_eq!(home_dir(), Some(PathBuf::from("/win-home"))); + + // Restore. + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match prev_userprofile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + } + + #[test] + fn expand_home_resolves_tilde_prefix() { + std::env::set_var("HOME", "/tmp/test-home"); + assert_eq!( + expand_home(Path::new("~/foo/bar")), + PathBuf::from("/tmp/test-home/foo/bar"), + ); + assert_eq!(expand_home(Path::new("~")), PathBuf::from("/tmp/test-home")); + // Non-tilde paths pass through. + assert_eq!( + expand_home(Path::new("/etc/passwd")), + PathBuf::from("/etc/passwd"), + ); + // Embedded ~ left alone. + assert_eq!( + expand_home(Path::new("/foo/~bar")), + PathBuf::from("/foo/~bar"), + ); + } + + // -------- Chain -------- + + #[test] + fn chain_picks_first_with_value() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::Missing, + AuthCredentialSource::literal("second"), + AuthCredentialSource::literal("third"), + ]); + assert_eq!(resolved(&s), Some("second".to_string())); + } + + #[test] + fn chain_returns_none_when_all_missing() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::Missing, + AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_DEFINITELY_UNSET"), + ]); + assert_eq!(resolved(&s), None); + } + + // -------- Cli -------- + + #[test] + fn cli_strips_leading_dashes() { + match AuthCredentialSource::cli("--api-token") { + AuthCredentialSource::Cli(n) => assert_eq!(n, "api-token"), + _ => panic!("expected Cli variant"), + } + match AuthCredentialSource::cli("api-token") { + AuthCredentialSource::Cli(n) => assert_eq!(n, "api-token"), + _ => panic!("expected Cli variant"), + } + } + + #[test] + fn cli_resolves_to_none_before_finalize() { + let s = AuthCredentialSource::cli("api-token"); + assert_eq!(resolved(&s), None); + } + + #[test] + fn cli_args_collects_recursively_through_chain() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("flag-a"), + AuthCredentialSource::from_env("X"), + AuthCredentialSource::any([AuthCredentialSource::cli("flag-b")]), + ]); + let args = s.cli_args(); + assert_eq!(args, vec!["flag-a", "flag-b"]); + } + + #[test] + fn cli_args_empty_when_no_cli_variants() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::from_env("X"), + AuthCredentialSource::literal("y"), + ]); + assert!(s.cli_args().is_empty()); + } + + fn build_matches(arg_name: &'static str, value: Option<&str>) -> Arc { + let cmd = clap::Command::new("test").arg( + clap::Arg::new(arg_name) + .long(arg_name) + .num_args(1), + ); + let argv: Vec = match value { + Some(v) => vec![ + "test".to_string(), + format!("--{arg_name}"), + v.to_string(), + ], + None => vec!["test".to_string()], + }; + Arc::new(cmd.try_get_matches_from(argv).unwrap()) + } + + #[test] + fn finalize_replaces_cli_with_closure_reading_matches() { + let matches = build_matches("api-token", Some("supplied-on-cli")); + let s = AuthCredentialSource::cli("api-token").finalize(&matches); + assert_eq!(resolved(&s), Some("supplied-on-cli".to_string())); + } + + #[test] + fn finalize_cli_returns_none_when_flag_absent() { + let matches = build_matches("api-token", None); + let s = AuthCredentialSource::cli("api-token").finalize(&matches); + assert_eq!(resolved(&s), None); + } + + #[test] + fn finalize_recurses_into_chain_with_cli_fallback_to_env() { + // Chain: --api-token (not passed) -> env var (set) -> file (missing) + let matches = build_matches("api-token", None); + std::env::set_var("FERN_CLI_AUTH_TEST_CHAIN_FALLBACK", "from-env"); + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_CHAIN_FALLBACK"), + ]) + .finalize(&matches); + assert_eq!(resolved(&s), Some("from-env".to_string())); + std::env::remove_var("FERN_CLI_AUTH_TEST_CHAIN_FALLBACK"); + } + + #[test] + fn finalize_chain_cli_wins_over_env() { + // CLI is registered FIRST in the chain — when both are present, + // CLI's value takes precedence. + let matches = build_matches("api-token", Some("from-cli")); + std::env::set_var("FERN_CLI_AUTH_TEST_CHAIN_PRECEDENCE", "from-env"); + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_CHAIN_PRECEDENCE"), + ]) + .finalize(&matches); + assert_eq!(resolved(&s), Some("from-cli".to_string())); + std::env::remove_var("FERN_CLI_AUTH_TEST_CHAIN_PRECEDENCE"); + } + + #[test] + fn finalize_passes_through_non_cli_variants() { + let matches = build_matches("ignored", None); + let s = AuthCredentialSource::literal("constant").finalize(&matches); + assert_eq!(resolved(&s), Some("constant".to_string())); + } + + #[test] + fn resolved_secret_does_not_leak_through_debug() { + // SecretString redacts its inner value in Debug — defense in + // depth against accidentally panic-printing or logging tokens. + let s = AuthCredentialSource::literal("super-secret-token"); + let secret = s.resolve().unwrap(); + let dbg = format!("{secret:?}"); + assert!(!dbg.contains("super-secret-token")); + } + + #[test] + fn debug_redacts_literal_value() { + let s = AuthCredentialSource::literal("super-secret"); + let dbg = format!("{s:?}"); + assert!(!dbg.contains("super-secret")); + assert!(dbg.contains("redacted")); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/error.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/error.rs new file mode 100644 index 000000000000..0c34ba048c90 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/error.rs @@ -0,0 +1,190 @@ +//! Auth-aware HTTP error mapping. +//! +//! On a 401/403 response, we want to surface a friendly "no credentials" +//! message when the request actually went out without working auth (the +//! user just needs to set their env var / file / flag), but pass the raw +//! server error through when the request *did* carry credentials (the +//! server is rejecting them — a real backend problem). +//! +//! Per-endpoint awareness comes from +//! [`AuthProvider::has_credentials_for`][hcf]: a routing wrapper can have +//! credentials for *some* schemes but not the one this specific endpoint +//! demanded, and the friendly path should still fire. +//! +//! [hcf]: crate::auth::AuthProvider::has_credentials_for + +use serde_json::Value; + +use crate::auth::provider::{AuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +/// Map an HTTP error response to a [`CliError`], honoring whether the +/// provider could have authenticated *this specific endpoint*. +/// +/// When `status` is 401/403 and the provider reports it couldn't satisfy +/// the endpoint's auth requirements, returns a friendly +/// [`CliError::Auth`] hinting the user to check their configured auth +/// source. Otherwise, parses the response body as a structured +/// `{ "error": { code, message, errors[].reason | reason } }` envelope +/// and returns [`CliError::Api`]; falls back to wrapping the raw body if +/// the response isn't JSON. +pub fn handle_error_response( + status: reqwest::StatusCode, + error_body: &str, + provider: &dyn AuthProvider, + endpoint: &EndpointAuthMetadata, +) -> Result { + if (status.as_u16() == 401 || status.as_u16() == 403) + && !provider.has_credentials_for(endpoint) + { + return Err(CliError::Auth( + "Access denied. This request was sent without authentication \ + credentials. Check that the configured auth source for this CLI \ + (environment variable, --flag, or credential file) has a value set." + .to_string(), + )); + } + Err(parse_api_error(status, error_body)) +} + +/// Shared parsing for the auth-aware error handler. Returns a structured +/// [`CliError::Api`] whether or not the body was JSON. +fn parse_api_error(status: reqwest::StatusCode, error_body: &str) -> CliError { + if let Ok(error_json) = serde_json::from_str::(error_body) { + if let Some(err_obj) = error_json.get("error") { + let code = err_obj + .get("code") + .and_then(|c| c.as_u64()) + .unwrap_or(status.as_u16() as u64) as u16; + let message = err_obj + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error") + .to_string(); + let reason = err_obj + .get("errors") + .and_then(|e| e.as_array()) + .and_then(|arr| arr.first()) + .and_then(|e| e.get("reason")) + .and_then(|r| r.as_str()) + .or_else(|| err_obj.get("reason").and_then(|r| r.as_str())) + .unwrap_or("unknown") + .to_string(); + return CliError::Api { + code, + message, + reason, + }; + } + } + CliError::Api { + code: status.as_u16(), + message: error_body.to_string(), + reason: "httpError".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::credential::AuthCredentialSource; + use crate::auth::schemes::BearerAuthProvider; + use serde_json::json; + + #[test] + fn friendly_when_provider_has_no_credentials_for_endpoint() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::Missing); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => assert!(msg.contains("Access denied")), + _ => panic!("Expected Auth"), + } + } + + #[test] + fn passes_through_when_credentials_present() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":{"code":401,"message":"bad","reason":"x"}}"#, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + assert!(matches!(err, CliError::Api { .. })); + } + + #[test] + fn parses_structured_error_envelope() { + let json_err = json!({ + "error": { + "code": 401, + "message": "Request had invalid authentication credentials.", + "errors": [{ "reason": "authError" }] + } + }) + .to_string(); + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + &json_err, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { code, message, reason } => { + assert_eq!(code, 401); + assert!(message.contains("invalid authentication credentials")); + assert_eq!(reason, "authError"); + } + other => panic!("Expected Api, got: {other:?}"), + } + } + + #[test] + fn handles_top_level_reason_field() { + let json_err = json!({ + "error": { "code": 403, "message": "Forbidden", "reason": "accessDenied" } + }) + .to_string(); + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::FORBIDDEN, + &json_err, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { reason, .. } => assert_eq!(reason, "accessDenied"), + _ => panic!("Expected Api"), + } + } + + #[test] + fn falls_back_to_raw_body_when_non_json() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error Text", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { code, message, reason } => { + assert_eq!(code, 500); + assert_eq!(message, "Internal Server Error Text"); + assert_eq!(reason, "httpError"); + } + _ => panic!("Expected Api"), + } + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/mod.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/mod.rs new file mode 100644 index 000000000000..6c7d7b703bb2 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/mod.rs @@ -0,0 +1,61 @@ +//! Authentication provider architecture. +//! +//! Modeled on the Fern TypeScript SDK generator's `core.AuthProvider` contract: +//! every auth scheme implements [`AuthProvider`], which mutates an outgoing +//! [`reqwest::RequestBuilder`] with the appropriate headers. Composition +//! wrappers let multiple schemes coexist: +//! +//! - [`AnyAuthProvider`] — OR semantics. Tries each child provider; the first +//! that contributes headers wins. Used when a CLI is configured with several +//! schemes but no per-endpoint security map (the default fallback). +//! - [`RoutingAuthProvider`] — per-endpoint dispatch. Reads the operation's +//! `security_requirements` (`security: [...]` in OpenAPI), finds the first +//! requirement that all registered providers can satisfy, and merges their +//! headers (AND inside a requirement, OR across requirements). +//! +//! Each scheme provider is parameterized by an [`AuthCredentialSource`] — a +//! lazy supplier that resolves a value from an env var, a literal, or a +//! closure. This mirrors the TS generator's `Supplier`. +//! +//! # Module layout +//! +//! - [`credential`] — `AuthCredentialSource` (lazy-supplier model with +//! env, CLI flag, file, literal, chain, and closure variants). +//! - [`provider`] — the [`AuthProvider`] trait, [`EndpointAuthMetadata`], +//! [`DynAuthProvider`] alias, and the [`NoAuthProvider`] sentinel. +//! - [`schemes`] — concrete [`BearerAuthProvider`], [`BasicAuthProvider`], +//! and [`HeaderAuthProvider`] implementations. +//! - [`compose`] — composition wrappers: [`AnyAuthProvider`], +//! [`AllAuthProvider`], [`RoutingAuthProvider`]. +//! - [`builder`] — [`SchemeBinding`], [`AuthStrategy`], and the +//! `build_provider_*` factories that `CliApp` calls. +//! - [`error`] — auth-aware HTTP error mapping (`handle_error_response`). +//! +//! All public types and functions are re-exported at the module root. + +pub mod builder; +pub mod compose; +pub mod credential; +pub mod error; +pub mod oauth2; +pub mod provider; +pub mod root_builder; +pub mod schemes; + +#[cfg(test)] +pub(crate) mod test_helpers; + +pub use builder::{ + build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, + collect_binding_cli_args, finalize_bindings, render_auth_help_section, AuthStrategy, + SchemeBinding, +}; +pub use error::handle_error_response; +pub use compose::{AllAuthProvider, AnyAuthProvider, RoutingAuthProvider}; +pub use credential::AuthCredentialSource; +pub use provider::{ + no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, +}; +pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; +pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/oauth2.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/oauth2.rs new file mode 100644 index 000000000000..9f761ea61c79 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/oauth2.rs @@ -0,0 +1,1210 @@ +//! OAuth 2.0 auth provider with persistent token storage. +//! +//! [`OAuth2TokenProvider`] implements [`AuthProvider`] so it plugs directly into +//! the `auth_provider()` builder method on `CliApp`. On first `apply()`: +//! +//! 1. Check the on-disk credential cache (`~/.config//credentials.json`). +//! If a cached access token exists and hasn't expired, use it. +//! 2. If the cache holds a refresh token, exchange it for a new access token +//! (RFC 6749 §6) and update the cache. +//! 3. Otherwise fall back to the configured grant (client credentials or +//! refresh token from env) and persist the result. +//! +//! This mirrors the token persistence patterns used by `gcloud`, `gh`, and +//! `aws sso`. Tokens are stored as JSON with owner-only file permissions +//! (0600) and written atomically via temp-file-then-rename. +//! +//! For the async token fetch to work inside the synchronous `apply()` +//! method, the provider uses `tokio::task::block_in_place` + +//! `Handle::current().block_on()`. This is safe because `CliApp::run` +//! creates a multi-threaded tokio runtime. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; + +use crate::auth::provider::{AuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// Token response parsing +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct TokenSuccessBody { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +#[derive(Deserialize)] +struct TokenErrorBody { + error: Option, + #[serde(rename = "error_description")] + error_description: Option, +} + +fn parse_oauth_error_json(body: &str) -> Option { + let err: TokenErrorBody = serde_json::from_str(body).ok()?; + match (err.error, err.error_description) { + (Some(e), Some(d)) => Some(format!("{e}: {d}")), + (Some(e), None) => Some(e), + (None, Some(d)) => Some(d), + (None, None) => None, + } +} + +fn truncate_body_for_error(body: &str) -> String { + const MAX: usize = 512; + let char_count = body.chars().count(); + if char_count <= MAX { + body.to_string() + } else { + let truncated: String = body.chars().take(MAX).collect(); + format!("{truncated}…") + } +} + +fn token_http_client() -> Result { + reqwest::Client::builder() + .build() + .map_err(|e| CliError::Auth(format!("Failed to build HTTP client for OAuth2: {e}"))) +} + +fn now_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +// --------------------------------------------------------------------------- +// On-disk token cache +// --------------------------------------------------------------------------- + +/// A single cached token entry, keyed by token_url in the JSON map. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedToken { + access_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option, + /// Epoch seconds when the access token expires. `None` = no expiry known. + #[serde(skip_serializing_if = "Option::is_none")] + expires_at: Option, +} + +/// On-disk credential store at `~/.config//credentials.json`. +/// +/// The file is a JSON object keyed by token_url: +/// ```json +/// { +/// "https://identity.example.com/connect/token": { +/// "access_token": "...", +/// "refresh_token": "...", +/// "expires_at": 1715550000 +/// } +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct TokenCache { + path: PathBuf, +} + +/// Buffer subtracted from `expires_in` before writing `expires_at`, +/// so we refresh before the token actually expires. 2 minutes matches +/// the TS SDK's BUFFER_IN_MINUTES constant. +const EXPIRY_BUFFER_SECS: u64 = 120; + +type TokenMap = std::collections::HashMap; + +impl TokenCache { + /// Build a cache path at `~/.config//credentials.json`. + pub fn for_cli(cli_name: &str) -> Option { + let home = home_dir()?; + let dir = config_dir(&home); + Some(Self { + path: dir.join(cli_name).join("credentials.json"), + }) + } + + /// Build a cache at an explicit path (for testing). + #[cfg(test)] + fn at_path(path: PathBuf) -> Self { + Self { path } + } + + fn read_map(&self) -> TokenMap { + let data = match std::fs::read_to_string(&self.path) { + Ok(d) => d, + Err(_) => return TokenMap::new(), + }; + serde_json::from_str(&data).unwrap_or_default() + } + + fn write_map(&self, map: &TokenMap) -> Result<(), CliError> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CliError::Auth(format!( + "Failed to create token cache directory {}: {e}", + parent.display() + )) + })?; + } + + let json = serde_json::to_string_pretty(map).map_err(|e| { + CliError::Auth(format!("Failed to serialize token cache: {e}")) + })?; + + atomic_write(&self.path, json.as_bytes()) + } + + /// Load a non-expired cached token for the given token_url. + fn load(&self, token_url: &str) -> Option { + let map = self.read_map(); + let entry = map.get(token_url)?; + if let Some(expires_at) = entry.expires_at { + if now_epoch_secs() >= expires_at { + return None; + } + } + Some(entry.clone()) + } + + /// Persist a token response to disk. + fn store( + &self, + token_url: &str, + access_token: &str, + refresh_token: Option<&str>, + expires_in: Option, + ) -> Result<(), CliError> { + let mut map = self.read_map(); + let expires_at = expires_in.map(|ei| { + let buffered = ei.saturating_sub(EXPIRY_BUFFER_SECS); + now_epoch_secs() + buffered + }); + // Preserve existing refresh_token if the new response didn't include one + let prev_refresh = map.get(token_url).and_then(|e| e.refresh_token.clone()); + map.insert( + token_url.to_string(), + CachedToken { + access_token: access_token.to_string(), + refresh_token: refresh_token + .map(|s| s.to_string()) + .or(prev_refresh), + expires_at, + }, + ); + self.write_map(&map) + } + + /// Remove the cached entry for a token_url (e.g., on refresh failure). + fn remove(&self, token_url: &str) { + let mut map = self.read_map(); + if map.remove(token_url).is_some() { + let _ = self.write_map(&map); + } + } +} + +/// Write `data` to `path` atomically: write a sibling temp file, set +/// owner-only permissions (0600 on Unix), then rename over the target. +fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, data).map_err(|e| { + CliError::Auth(format!( + "Failed to write token cache {}: {e}", + tmp.display() + )) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&tmp, perms); + } + + std::fs::rename(&tmp, path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + CliError::Auth(format!( + "Failed to rename token cache {}: {e}", + tmp.display() + )) + }) +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) +} + +/// Platform-appropriate config directory. +fn config_dir(home: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + { + home.join("Library").join("Application Support") + } + #[cfg(target_os = "windows")] + { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Roaming")) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".config")) + } +} + +// --------------------------------------------------------------------------- +// Grant configuration +// --------------------------------------------------------------------------- + +/// Which OAuth2 grant type to use. +#[derive(Debug, Clone)] +pub enum OAuth2Grant { + /// Client credentials grant (RFC 6749 §4.4). + ClientCredentials { + /// Env var name for the client ID. + client_id_env: String, + /// Env var name for the client secret. + client_secret_env: String, + /// Optional space-delimited scope string. + scope: Option, + }, + /// Refresh token grant (RFC 6749 §6). + RefreshToken { + /// Env var name for the client ID. + client_id_env: String, + /// Env var name for the client secret. + client_secret_env: String, + /// Env var name for the refresh token. + refresh_token_env: String, + }, +} + +// --------------------------------------------------------------------------- +// Form bodies (serde) +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct ClientCredentialsForm<'a> { + grant_type: &'static str, + client_id: &'a str, + client_secret: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option<&'a str>, +} + +#[derive(Serialize)] +struct RefreshTokenForm<'a> { + grant_type: &'static str, + client_id: &'a str, + client_secret: &'a str, + refresh_token: &'a str, +} + +// --------------------------------------------------------------------------- +// Token fetch +// --------------------------------------------------------------------------- + +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +async fn fetch_token(token_url: &str, grant: &OAuth2Grant) -> Result { + if token_url.trim().is_empty() { + return Err(CliError::Validation( + "OAuth2: token_url must not be empty".to_string(), + )); + } + + let http = token_http_client()?; + + let response = match grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + scope, + } => { + let client_id = read_env(client_id_env, "client_id")?; + let client_secret = read_env(client_secret_env, "client_secret")?; + http.post(token_url) + .form(&ClientCredentialsForm { + grant_type: "client_credentials", + client_id: &client_id, + client_secret: &client_secret, + scope: scope.as_deref(), + }) + .send() + .await + } + OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + refresh_token_env, + } => { + let client_id = read_env(client_id_env, "client_id")?; + let client_secret = read_env(client_secret_env, "client_secret")?; + let refresh_token = read_env(refresh_token_env, "refresh_token")?; + http.post(token_url) + .form(&RefreshTokenForm { + grant_type: "refresh_token", + client_id: &client_id, + client_secret: &client_secret, + refresh_token: &refresh_token, + }) + .send() + .await + } + } + .map_err(|e| CliError::Auth(format!("OAuth2 token request failed: {e}")))?; + + parse_token_response(response).await +} + +/// Exchange a cached refresh token for a new access token. +async fn refresh_cached_token( + token_url: &str, + client_id: &str, + client_secret: &str, + refresh_token: &str, +) -> Result { + let http = token_http_client()?; + let response = http + .post(token_url) + .form(&RefreshTokenForm { + grant_type: "refresh_token", + client_id, + client_secret, + refresh_token, + }) + .send() + .await + .map_err(|e| CliError::Auth(format!("OAuth2 token refresh failed: {e}")))?; + parse_token_response(response).await +} + +async fn parse_token_response(response: reqwest::Response) -> Result { + let status = response.status(); + let body_text = response + .text() + .await + .map_err(|e| CliError::Auth(format!("OAuth2 token response body: {e}")))?; + + if !status.is_success() { + let detail = parse_oauth_error_json(&body_text) + .unwrap_or_else(|| truncate_body_for_error(&body_text)); + return Err(CliError::Auth(format!( + "OAuth2 token endpoint returned HTTP {status}: {detail}" + ))); + } + + let parsed: TokenSuccessBody = serde_json::from_str(&body_text).map_err(|e| { + CliError::Auth(format!( + "OAuth2 token response is not valid JSON with access_token: {e}" + )) + })?; + + if parsed.access_token.is_empty() { + return Err(CliError::Auth( + "OAuth2 token response contained an empty access_token".to_string(), + )); + } + + Ok(TokenResponse { + access_token: parsed.access_token, + refresh_token: parsed.refresh_token, + expires_in: parsed.expires_in, + }) +} + +fn read_env(var: &str, label: &str) -> Result { + let val = std::env::var(var).map_err(|_| { + CliError::Auth(format!( + "Missing environment variable {var} (OAuth2 {label})" + )) + })?; + if val.is_empty() { + return Err(CliError::Auth(format!( + "Environment variable {var} (OAuth2 {label}) must be non-empty" + ))); + } + Ok(val) +} + +// --------------------------------------------------------------------------- +// OAuth2TokenProvider +// --------------------------------------------------------------------------- + +/// OAuth2 auth provider with on-disk token persistence. +/// +/// Resolution order on each `apply()`: +/// 1. In-process cache (`OnceLock`) — already resolved this invocation. +/// 2. On-disk cache — non-expired access token from a previous invocation. +/// 3. Cached refresh token — exchange for a new access token. +/// 4. Configured grant (client credentials or env-based refresh token). +/// +/// New tokens are persisted to `~/.config//credentials.json` (Linux), +/// `~/Library/Application Support//credentials.json` (macOS), or +/// `%APPDATA%//credentials.json` (Windows). +pub struct OAuth2TokenProvider { + scheme_name: String, + token_url: String, + grant: OAuth2Grant, + cache: Option, + cached_token: OnceLock>, +} + +impl std::fmt::Debug for OAuth2TokenProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuth2TokenProvider") + .field("scheme_name", &self.scheme_name) + .field("token_url", &self.token_url) + .field("grant", &self.grant) + .finish() + } +} + +impl OAuth2TokenProvider { + pub fn new( + scheme_name: impl Into, + token_url: impl Into, + grant: OAuth2Grant, + ) -> Self { + Self { + scheme_name: scheme_name.into(), + token_url: token_url.into(), + grant, + cache: None, + cached_token: OnceLock::new(), + } + } + + /// Enable on-disk token persistence. `cli_name` is the binary name + /// (e.g., `"myapi"`) — tokens are stored under the platform config dir. + pub fn with_cache(mut self, cli_name: &str) -> Self { + self.cache = TokenCache::for_cli(cli_name); + self + } + + /// Enable on-disk token persistence with a pre-built [`TokenCache`]. + pub fn with_token_cache(mut self, cache: TokenCache) -> Self { + self.cache = Some(cache); + self + } + + fn resolve_token(&self) -> Result<&SecretString, CliError> { + let result = self.cached_token.get_or_init(|| { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.resolve_token_async()) + .map(SecretString::from) + .map_err(|e| e.to_string()) + }) + }); + match result { + Ok(token) => Ok(token), + Err(msg) => Err(CliError::Auth(msg.clone())), + } + } + + async fn resolve_token_async(&self) -> Result { + // 1. Check on-disk cache for a valid access token + if let Some(cache) = &self.cache { + if let Some(cached) = cache.load(&self.token_url) { + tracing::debug!("Using cached OAuth2 access token for {}", self.token_url); + return Ok(cached.access_token); + } + + // 2. Try refreshing with a cached refresh token + if let Some(token_resp) = self.try_cached_refresh(cache).await { + return Ok(token_resp); + } + } + + // 3. Fall back to the configured grant + let resp = fetch_token(&self.token_url, &self.grant).await?; + self.persist_response(&resp); + Ok(resp.access_token) + } + + /// Attempt to use a cached refresh token. Returns the new access token + /// on success, or None if there's no cached refresh token or the refresh + /// fails (in which case we fall through to the configured grant). + async fn try_cached_refresh(&self, cache: &TokenCache) -> Option { + let map = cache.read_map(); + let entry = map.get(&self.token_url)?; + let refresh_token = entry.refresh_token.as_deref()?; + + // We need client_id and client_secret to do the refresh + let (client_id_env, client_secret_env) = match &self.grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + .. + } => (client_id_env.as_str(), client_secret_env.as_str()), + OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + .. + } => (client_id_env.as_str(), client_secret_env.as_str()), + }; + + let client_id = match read_env(client_id_env, "client_id") { + Ok(v) => v, + Err(_) => { + tracing::debug!( + "Cannot refresh cached token: {} not set", + client_id_env + ); + return None; + } + }; + let client_secret = match read_env(client_secret_env, "client_secret") { + Ok(v) => v, + Err(_) => { + tracing::debug!( + "Cannot refresh cached token: {} not set", + client_secret_env + ); + return None; + } + }; + + tracing::debug!( + "Attempting cached refresh token grant for {}", + self.token_url + ); + + match refresh_cached_token( + &self.token_url, + &client_id, + &client_secret, + refresh_token, + ) + .await + { + Ok(resp) => { + self.persist_response(&resp); + Some(resp.access_token) + } + Err(e) => { + tracing::debug!("Cached refresh token failed, falling through: {e}"); + cache.remove(&self.token_url); + None + } + } + } + + fn persist_response(&self, resp: &TokenResponse) { + if let Some(cache) = &self.cache { + if let Err(e) = cache.store( + &self.token_url, + &resp.access_token, + resp.refresh_token.as_deref(), + resp.expires_in, + ) { + tracing::warn!("Failed to persist OAuth2 token to cache: {e}"); + } + } + } +} + +impl AuthProvider for OAuth2TokenProvider { + fn name(&self) -> &str { + &self.scheme_name + } + + fn has_credentials(&self) -> bool { + // Check disk cache first — if we have a cached token, we have creds + if let Some(cache) = &self.cache { + if cache.load(&self.token_url).is_some() { + return true; + } + // Also check if we have a cached refresh token (even if access expired) + let map = cache.read_map(); + if let Some(entry) = map.get(&self.token_url) { + if entry.refresh_token.is_some() { + return true; + } + } + } + // Fall back to env var check + match &self.grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + .. + } => env_is_set(client_id_env) && env_is_set(client_secret_env), + OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + refresh_token_env, + } => { + env_is_set(client_id_env) + && env_is_set(client_secret_env) + && env_is_set(refresh_token_env) + } + } + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let token = self.resolve_token()?; + let mut value = String::with_capacity(7 + token.expose_secret().len()); + value.push_str("Bearer "); + value.push_str(token.expose_secret()); + let mut header = reqwest::header::HeaderValue::from_str(&value) + .map_err(|e| CliError::Auth(format!("Invalid OAuth2 bearer token: {e}")))?; + header.set_sensitive(true); + Ok(request.header(reqwest::header::AUTHORIZATION, header)) + } +} + +fn env_is_set(var: &str) -> bool { + std::env::var(var) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::test_helpers::{auth_header, req}; + use serial_test::serial; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn client_credentials_fetches_and_caches_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "cc-token-123", + "token_type": "Bearer" + })), + ) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_CC_ID", "my-id"); + std::env::set_var("TEST_CC_SECRET", "my-secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + format!("{}/token", server.uri()), + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_CC_ID".to_string(), + client_secret_env: "TEST_CC_SECRET".to_string(), + scope: None, + }, + ); + + assert!(provider.has_credentials()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer cc-token-123")); + + // Second call uses in-process cache (wiremock expect(1) would fail otherwise) + let r2 = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r2).as_deref(), Some("Bearer cc-token-123")); + + std::env::remove_var("TEST_CC_ID"); + std::env::remove_var("TEST_CC_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn client_credentials_no_creds_when_env_unset() { + std::env::remove_var("MISSING_CC_ID_XYZ"); + std::env::remove_var("MISSING_CC_SECRET_XYZ"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://unused.example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "MISSING_CC_ID_XYZ".to_string(), + client_secret_env: "MISSING_CC_SECRET_XYZ".to_string(), + scope: None, + }, + ); + + assert!(!provider.has_credentials()); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn refresh_token_fetches_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "refreshed-token-456", + "token_type": "Bearer" + })), + ) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_RT_ID", "my-id"); + std::env::set_var("TEST_RT_SECRET", "my-secret"); + std::env::set_var("TEST_RT_REFRESH", "my-refresh-token"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + format!("{}/token", server.uri()), + OAuth2Grant::RefreshToken { + client_id_env: "TEST_RT_ID".to_string(), + client_secret_env: "TEST_RT_SECRET".to_string(), + refresh_token_env: "TEST_RT_REFRESH".to_string(), + }, + ); + + assert!(provider.has_credentials()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer refreshed-token-456")); + + std::env::remove_var("TEST_RT_ID"); + std::env::remove_var("TEST_RT_SECRET"); + std::env::remove_var("TEST_RT_REFRESH"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn refresh_token_no_creds_without_refresh_env() { + std::env::set_var("TEST_RT_ID2", "id"); + std::env::set_var("TEST_RT_SECRET2", "secret"); + std::env::remove_var("MISSING_RT_XYZ"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://unused.example.com/token", + OAuth2Grant::RefreshToken { + client_id_env: "TEST_RT_ID2".to_string(), + client_secret_env: "TEST_RT_SECRET2".to_string(), + refresh_token_env: "MISSING_RT_XYZ".to_string(), + }, + ); + + assert!(!provider.has_credentials()); + + std::env::remove_var("TEST_RT_ID2"); + std::env::remove_var("TEST_RT_SECRET2"); + } + + #[test] + fn parse_oauth_error_prefers_error_and_description() { + let body = r#"{"error":"invalid_client","error_description":"bad secret"}"#; + assert_eq!( + parse_oauth_error_json(body).as_deref(), + Some("invalid_client: bad secret") + ); + } + + #[test] + fn truncate_body_long() { + let s = "x".repeat(600); + let t = truncate_body_for_error(&s); + assert!(t.len() < s.len()); + assert!(t.ends_with('…')); + } + + #[test] + fn truncate_body_multibyte_utf8_no_panic() { + let s = "é".repeat(600); + let t = truncate_body_for_error(&s); + assert!(t.chars().count() <= 513); // 512 chars + '…' + assert!(t.ends_with('…')); + } + + // ---- Token cache tests ---- + + #[test] + fn token_cache_store_and_load() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store( + "https://example.com/token", + "access-abc", + Some("refresh-xyz"), + Some(3600), + ) + .unwrap(); + + let loaded = cache.load("https://example.com/token").unwrap(); + assert_eq!(loaded.access_token, "access-abc"); + assert_eq!(loaded.refresh_token.as_deref(), Some("refresh-xyz")); + assert!(loaded.expires_at.is_some()); + } + + #[test] + fn token_cache_expired_token_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + // Store a token with 0 seconds expiry (immediately expired after buffer) + cache + .store("https://example.com/token", "expired", None, Some(0)) + .unwrap(); + + assert!(cache.load("https://example.com/token").is_none()); + } + + #[test] + fn token_cache_no_expiry_always_valid() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store("https://example.com/token", "forever", None, None) + .unwrap(); + + let loaded = cache.load("https://example.com/token").unwrap(); + assert_eq!(loaded.access_token, "forever"); + assert!(loaded.expires_at.is_none()); + } + + #[test] + fn token_cache_remove() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store("https://example.com/token", "abc", None, Some(3600)) + .unwrap(); + assert!(cache.load("https://example.com/token").is_some()); + + cache.remove("https://example.com/token"); + assert!(cache.load("https://example.com/token").is_none()); + } + + #[test] + fn token_cache_preserves_refresh_token_on_update() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + // Initial store with refresh token + cache + .store("https://ex.com/t", "old-access", Some("my-refresh"), Some(3600)) + .unwrap(); + + // Update with new access token but no refresh token in response + cache + .store("https://ex.com/t", "new-access", None, Some(3600)) + .unwrap(); + + let loaded = cache.load("https://ex.com/t").unwrap(); + assert_eq!(loaded.access_token, "new-access"); + assert_eq!(loaded.refresh_token.as_deref(), Some("my-refresh")); + } + + #[cfg(unix)] + #[test] + fn token_cache_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + let cache = TokenCache::at_path(path.clone()); + + cache + .store("https://example.com/token", "secret", None, None) + .unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "Token cache should be owner-only"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_uses_disk_cache() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + // Pre-populate the cache + cache + .store("https://example.com/token", "cached-token", None, Some(3600)) + .unwrap(); + + // Provider should not hit the network (no MockServer needed) + std::env::set_var("TEST_CACHE_ID", "id"); + std::env::set_var("TEST_CACHE_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_CACHE_ID".to_string(), + client_secret_env: "TEST_CACHE_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache); + + assert!(provider.has_credentials()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer cached-token")); + + std::env::remove_var("TEST_CACHE_ID"); + std::env::remove_var("TEST_CACHE_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_persists_token_to_disk() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + let server = MockServer::start().await; + let token_url = format!("{}/token", server.uri()); + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "new-token", + "refresh_token": "new-refresh", + "expires_in": 3600 + })), + ) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_PERSIST_ID", "id"); + std::env::set_var("TEST_PERSIST_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + &token_url, + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_PERSIST_ID".to_string(), + client_secret_env: "TEST_PERSIST_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache.clone()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer new-token")); + + // Verify it was persisted + let loaded = cache.load(&token_url).unwrap(); + assert_eq!(loaded.access_token, "new-token"); + assert_eq!(loaded.refresh_token.as_deref(), Some("new-refresh")); + + std::env::remove_var("TEST_PERSIST_ID"); + std::env::remove_var("TEST_PERSIST_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_uses_cached_refresh_token() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + let server = MockServer::start().await; + let token_url = format!("{}/token", server.uri()); + + // Pre-populate cache with expired access + valid refresh + { + let mut map = TokenMap::new(); + map.insert( + token_url.clone(), + CachedToken { + access_token: "expired".to_string(), + refresh_token: Some("cached-refresh".to_string()), + expires_at: Some(0), // already expired + }, + ); + let json = serde_json::to_string_pretty(&map).unwrap(); + std::fs::write(dir.path().join("credentials.json"), json).unwrap(); + } + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "refreshed-from-cache", + "refresh_token": "new-refresh", + "expires_in": 7200 + })), + ) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_CREFRESH_ID", "id"); + std::env::set_var("TEST_CREFRESH_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + &token_url, + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_CREFRESH_ID".to_string(), + client_secret_env: "TEST_CREFRESH_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache.clone()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!( + auth_header(r).as_deref(), + Some("Bearer refreshed-from-cache") + ); + + // Verify the new tokens were persisted + let loaded = cache.load(&token_url).unwrap(); + assert_eq!(loaded.access_token, "refreshed-from-cache"); + assert_eq!(loaded.refresh_token.as_deref(), Some("new-refresh")); + + std::env::remove_var("TEST_CREFRESH_ID"); + std::env::remove_var("TEST_CREFRESH_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_falls_through_when_cached_refresh_fails() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + let server = MockServer::start().await; + let token_url = format!("{}/token", server.uri()); + + // Pre-populate cache with expired access + stale refresh + { + let mut map = TokenMap::new(); + map.insert( + token_url.clone(), + CachedToken { + access_token: "expired".to_string(), + refresh_token: Some("stale-refresh".to_string()), + expires_at: Some(0), + }, + ); + let json = serde_json::to_string_pretty(&map).unwrap(); + std::fs::write(dir.path().join("credentials.json"), json).unwrap(); + } + + // First call (refresh) fails, second call (client credentials) succeeds + let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + let call_count_clone = call_count.clone(); + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(move |_req: &wiremock::Request| { + let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + // Refresh fails + ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "refresh token expired" + })) + } else { + // Client credentials succeeds + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "fresh-cc-token", + "expires_in": 3600 + })) + } + }) + .expect(2) + .mount(&server) + .await; + + std::env::set_var("TEST_FALLTHRU_ID", "id"); + std::env::set_var("TEST_FALLTHRU_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + &token_url, + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_FALLTHRU_ID".to_string(), + client_secret_env: "TEST_FALLTHRU_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache.clone()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer fresh-cc-token")); + + // The stale refresh token should have been removed + let loaded = cache.load(&token_url).unwrap(); + assert_eq!(loaded.access_token, "fresh-cc-token"); + assert!(loaded.refresh_token.is_none()); + + std::env::remove_var("TEST_FALLTHRU_ID"); + std::env::remove_var("TEST_FALLTHRU_SECRET"); + } + + #[test] + fn has_credentials_true_when_cache_has_valid_token() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store("https://example.com/token", "valid", None, Some(3600)) + .unwrap(); + + std::env::remove_var("NO_SUCH_ID_XYZ_TOKEN_TEST"); + std::env::remove_var("NO_SUCH_SECRET_XYZ_TOKEN_TEST"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "NO_SUCH_ID_XYZ_TOKEN_TEST".to_string(), + client_secret_env: "NO_SUCH_SECRET_XYZ_TOKEN_TEST".to_string(), + scope: None, + }, + ) + .with_token_cache(cache); + + // has_credentials is true because of disk cache, even though env vars are unset + assert!(provider.has_credentials()); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/provider.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/provider.rs new file mode 100644 index 000000000000..9ab3470d3e7b --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/provider.rs @@ -0,0 +1,192 @@ +//! The [`AuthProvider`] trait, its per-request metadata +//! ([`EndpointAuthMetadata`]), the [`DynAuthProvider`] handle alias, and +//! the [`NoAuthProvider`] sentinel. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::error::CliError; + +/// Per-request context the executor passes to providers. Maps directly to +/// the TS generator's `endpointMetadata` argument. +/// +/// Three states encode OpenAPI's three semantics: +/// - `None` — the operation didn't pin a security policy. The composition +/// wrapper's default (typically `AnyAuthProvider`) handles it. +/// - `Some(vec![])` — explicitly anonymous (`security: []` in the spec). +/// The provider must not attach any auth, even if credentials are available. +/// - `Some(vec![req1, req2, ...])` — OR-of-ANDs: satisfy any one requirement. +#[derive(Debug, Clone, Default)] +pub struct EndpointAuthMetadata { + pub security_requirements: Option>>>, +} + +impl EndpointAuthMetadata { + /// No security policy declared on the operation — let the wrapper's + /// default policy decide. + pub fn unspecified() -> Self { + Self::default() + } + + /// `security: []` in the spec — operation is explicitly unauthenticated. + pub fn explicit_anonymous() -> Self { + Self { + security_requirements: Some(Vec::new()), + } + } + + pub fn with_requirements(reqs: Vec>>) -> Self { + Self { + security_requirements: Some(reqs), + } + } + + /// True when the operation pinned `security: []` — the spec's "this + /// endpoint is explicitly unauthenticated" signal. The executor uses + /// this to short-circuit `apply` so credentials never leak onto an + /// opt-out endpoint, regardless of which provider is configured. + pub fn is_explicit_anonymous(&self) -> bool { + matches!(&self.security_requirements, Some(reqs) if reqs.is_empty()) + } +} + +/// A pluggable authentication scheme. +/// +/// Implementors mutate `request` with the appropriate headers (or other +/// modifications) for an outgoing API call. Returning the request unchanged +/// is the right behaviour when the provider can't satisfy this request and +/// composition wrappers should fall through to the next provider. +/// +/// # Repeated credential resolution +/// +/// Composition wrappers (`AnyAuthProvider`, `AllAuthProvider`, +/// `RoutingAuthProvider`) call `has_credentials` / `has_credentials_for` +/// before `apply` on each request, so an +/// [`AuthCredentialSource`](crate::auth::AuthCredentialSource) backing a +/// leaf provider can be resolved twice (or more, through nested wrappers). +/// For `Env` / `Literal` / `Cli` sources this is free; for `File` it means +/// a re-read on each call and for `Closure` it means re-invocation. This +/// is acceptable for the CLI workload (one request per process invocation), +/// but provider implementations that wrap an expensive source — token +/// refresh, keychain access, network round-trips — should memoize +/// internally rather than expect the trait to deduplicate calls. +pub trait AuthProvider: Send + Sync + std::fmt::Debug { + /// Stable identifier. Used by [`RoutingAuthProvider`][rap] to look up + /// the provider for a security requirement and by error messages. Should + /// match the scheme name from the OpenAPI spec where applicable. + /// + /// [rap]: crate::auth::RoutingAuthProvider + fn name(&self) -> &str; + + /// Whether this provider currently has *any* credential available. + /// Used by composition wrappers to decide whether to try this provider + /// at all (e.g., `AnyAuthProvider` skips children whose + /// `has_credentials()` is false). + /// + /// For "could this provider have authenticated *this specific + /// endpoint*?" — used by the friendly-error path on 401/403 — see + /// [`has_credentials_for`](Self::has_credentials_for) instead. + fn has_credentials(&self) -> bool; + + /// Whether this provider can satisfy *this specific endpoint*'s auth + /// requirements. Used by the error path to decide whether a 401/403 is + /// the user's fault (no creds for this endpoint → friendly error) or + /// actually a server problem (creds were sent → surface raw error). + /// + /// The default delegates to [`has_credentials`](Self::has_credentials), + /// which is correct for leaf providers (bearer, basic, header) and for + /// `AnyAuthProvider` (any provider with creds will be tried regardless + /// of endpoint). Composition wrappers that route by endpoint — + /// notably [`RoutingAuthProvider`] — should override this to inspect + /// the endpoint's `security_requirements` and check whether any + /// requirement is satisfiable. + fn has_credentials_for(&self, _endpoint: &EndpointAuthMetadata) -> bool { + self.has_credentials() + } + + /// Apply the scheme to `request`. Implementations should be a no-op if + /// they can't satisfy the request (e.g., no env var set), so wrappers can + /// fall through. Hard errors (malformed token bytes) are surfaced via + /// [`CliError::Auth`]. + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result; +} + +/// Boxed handle the rest of the codebase passes around. +pub type DynAuthProvider = Arc; + +/// Construct a no-op [`AuthProvider`] handle. Use this in tests and in +/// custom command handlers that want to bypass auth for a one-off call. +pub fn no_auth_provider() -> DynAuthProvider { + Arc::new(NoAuthProvider) +} + +/// No-op provider. Used when the CLI hasn't configured auth at all. +#[derive(Debug, Clone, Default)] +pub struct NoAuthProvider; + +impl AuthProvider for NoAuthProvider { + fn name(&self) -> &str { + "none" + } + + fn has_credentials(&self) -> bool { + false + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + Ok(request) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::test_helpers::{auth_header, req}; + + #[tokio::test] + async fn no_auth_provider_emits_no_headers() { + let p = NoAuthProvider; + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r), None); + assert!(!p.has_credentials()); + assert_eq!(p.name(), "none"); + } + + #[test] + fn endpoint_metadata_three_states() { + // `unspecified` and `default` agree. + assert!(EndpointAuthMetadata::unspecified() + .security_requirements + .is_none()); + assert!(EndpointAuthMetadata::default() + .security_requirements + .is_none()); + + // `explicit_anonymous` is `Some(empty)`. + let anon = EndpointAuthMetadata::explicit_anonymous(); + assert_eq!( + anon.security_requirements.as_ref().map(|v| v.len()), + Some(0), + ); + + // `with_requirements` carries them through. + let reqs = vec![{ + let mut m = HashMap::new(); + m.insert("a".to_string(), Vec::::new()); + m + }]; + let with = EndpointAuthMetadata::with_requirements(reqs); + assert_eq!( + with.security_requirements.as_ref().map(|v| v.len()), + Some(1), + ); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/root_builder.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! Typed auth-scheme builders for root-level `CliApp` registration. +//! +//! These builders provide a type-safe, discoverable API for declaring auth +//! at the CLI level. Each builder produces the underlying `(String, SchemeBinding)` +//! pair consumed by the existing auth infrastructure. +//! +//! # Example +//! +//! ```rust,no_run +//! use fern_cli_sdk::app::CliApp; +//! use fern_cli_sdk::auth::{BearerAuth, ApiKeyAuth, BasicAuth, OAuth2Auth}; +//! use fern_cli_sdk::openapi::OpenApiBinding; +//! +//! CliApp::new("platform") +//! .auth(BearerAuth::new("bearerAuth").env("PLATFORM_TOKEN")) +//! .auth(ApiKeyAuth::new("apiKey").env("API_KEY")) +//! .auth(BasicAuth::new("basicAuth").username_env("USER").password_env("PASS")) +//! .auth(OAuth2Auth::new("OAuth2Security").client_id_env("ID").client_secret_env("SECRET").token_url("https://auth.example.com/token")) +//! .binding(OpenApiBinding::new().spec("openapi: '3.0.0'\ninfo:\n title: x\n version: '1'\npaths: {}")) +//! .run(); +//! ``` + +use super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// Trait implemented by all typed auth builders. Converts the builder +/// into the `(scheme_name, SchemeBinding)` pair used by the auth +/// infrastructure. +pub trait AuthSchemeBuilder { + /// Consume the builder and produce a `(scheme_name, SchemeBinding)` pair. + fn into_binding(self) -> (String, SchemeBinding); +} + +// --------------------------------------------------------------------------- +// BearerAuth — Authorization: Bearer +// --------------------------------------------------------------------------- + +/// Builder for bearer token authentication (`Authorization: Bearer `). +/// +/// The scheme name must match the `securitySchemes` key in the binding's spec. +#[derive(Debug, Clone)] +pub struct BearerAuth { + name: String, + source: AuthCredentialSource, +} + +impl BearerAuth { + /// Create a new bearer auth builder. `name` must match the scheme name + /// declared in the spec's `components.securitySchemes`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + source: AuthCredentialSource::Missing, + } + } + + /// Read the bearer token from an environment variable. + pub fn env(mut self, var_name: impl Into) -> Self { + self.source = AuthCredentialSource::from_env(var_name); + self + } + + /// Read the bearer token from a CLI flag (`--`). + pub fn cli(mut self, arg_name: impl Into) -> Self { + self.source = AuthCredentialSource::cli(arg_name); + self + } + + /// Read the bearer token from a file. + pub fn file(mut self, path: impl Into) -> Self { + self.source = AuthCredentialSource::file(path.into()); + self + } + + /// Use a fallback chain: try env, then CLI, then file, etc. + pub fn source(mut self, source: AuthCredentialSource) -> Self { + self.source = source; + self + } +} + +impl AuthSchemeBuilder for BearerAuth { + fn into_binding(self) -> (String, SchemeBinding) { + (self.name, SchemeBinding::Token(self.source)) + } +} + +// --------------------------------------------------------------------------- +// ApiKeyAuth — header or query-parameter API key +// --------------------------------------------------------------------------- + +/// Builder for API key authentication (header-based or query-parameter). +/// +/// The scheme name must match the `securitySchemes` key in the binding's spec. +/// The header name is read from the spec's `in: header` / `name: X-API-Key` +/// declaration; it does NOT need to be set here unless overriding. +#[derive(Debug, Clone)] +pub struct ApiKeyAuth { + name: String, + source: AuthCredentialSource, +} + +impl ApiKeyAuth { + /// Create a new API key auth builder. `name` must match the scheme name + /// declared in the spec's `components.securitySchemes`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + source: AuthCredentialSource::Missing, + } + } + + /// Read the API key from an environment variable. + pub fn env(mut self, var_name: impl Into) -> Self { + self.source = AuthCredentialSource::from_env(var_name); + self + } + + /// Read the API key from a CLI flag (`--`). + pub fn cli(mut self, arg_name: impl Into) -> Self { + self.source = AuthCredentialSource::cli(arg_name); + self + } + + /// Read the API key from a file. + pub fn file(mut self, path: impl Into) -> Self { + self.source = AuthCredentialSource::file(path.into()); + self + } + + /// Use a custom credential source. + pub fn source(mut self, source: AuthCredentialSource) -> Self { + self.source = source; + self + } +} + +impl AuthSchemeBuilder for ApiKeyAuth { + fn into_binding(self) -> (String, SchemeBinding) { + (self.name, SchemeBinding::Token(self.source)) + } +} + +// --------------------------------------------------------------------------- +// BasicAuth — HTTP Basic authentication +// --------------------------------------------------------------------------- + +/// Builder for HTTP Basic authentication (`Authorization: Basic base64(user:pass)`). +/// +/// The scheme name must match the `securitySchemes` key in the binding's spec. +#[derive(Debug, Clone)] +pub struct BasicAuth { + name: String, + username: AuthCredentialSource, + password: AuthCredentialSource, +} + +impl BasicAuth { + /// Create a new basic auth builder. `name` must match the scheme name + /// declared in the spec's `components.securitySchemes`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + username: AuthCredentialSource::Missing, + password: AuthCredentialSource::Missing, + } + } + + /// Read the username from an environment variable. + pub fn username_env(mut self, var_name: impl Into) -> Self { + self.username = AuthCredentialSource::from_env(var_name); + self + } + + /// Read the password from an environment variable. + pub fn password_env(mut self, var_name: impl Into) -> Self { + self.password = AuthCredentialSource::from_env(var_name); + self + } + + /// Read the username from a CLI flag. + pub fn username_cli(mut self, arg_name: impl Into) -> Self { + self.username = AuthCredentialSource::cli(arg_name); + self + } + + /// Read the password from a CLI flag. + pub fn password_cli(mut self, arg_name: impl Into) -> Self { + self.password = AuthCredentialSource::cli(arg_name); + self + } + + /// Set a custom credential source for the username. + pub fn username_source(mut self, source: AuthCredentialSource) -> Self { + self.username = source; + self + } + + /// Set a custom credential source for the password. + pub fn password_source(mut self, source: AuthCredentialSource) -> Self { + self.password = source; + self + } +} + +impl AuthSchemeBuilder for BasicAuth { + fn into_binding(self) -> (String, SchemeBinding) { + ( + self.name, + SchemeBinding::Basic { + username: self.username, + password: self.password, + }, + ) + } +} + +// --------------------------------------------------------------------------- +// OAuth2Auth — OAuth2 flows (client-credentials, refresh-token, PKCE) +// --------------------------------------------------------------------------- + +/// Builder for OAuth2 authentication. +/// +/// The scheme name must match the `securitySchemes` key in the binding's spec. +/// The token URL is embedded by the generator (from the spec's +/// `securitySchemes.*.flows.clientCredentials.tokenUrl` or Fern IR). +/// +/// At runtime, this resolves to a bearer token — the OAuth2 flow is +/// handled by the binding's executor using the token URL and credentials +/// declared here. +#[derive(Debug, Clone)] +pub struct OAuth2Auth { + name: String, + client_id: AuthCredentialSource, + client_secret: AuthCredentialSource, + access_token: AuthCredentialSource, + refresh_token: AuthCredentialSource, + token_url: Option, +} + +impl OAuth2Auth { + /// Create a new OAuth2 auth builder. `name` must match the scheme name + /// declared in the spec's `components.securitySchemes`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + client_id: AuthCredentialSource::Missing, + client_secret: AuthCredentialSource::Missing, + access_token: AuthCredentialSource::Missing, + refresh_token: AuthCredentialSource::Missing, + token_url: None, + } + } + + /// Set the OAuth2 token endpoint URL (from spec or Fern IR). + pub fn token_url(mut self, url: impl Into) -> Self { + self.token_url = Some(url.into()); + self + } + + /// Read the client ID from an environment variable. + pub fn client_id_env(mut self, var_name: impl Into) -> Self { + self.client_id = AuthCredentialSource::from_env(var_name); + self + } + + /// Read the client secret from an environment variable. + pub fn client_secret_env(mut self, var_name: impl Into) -> Self { + self.client_secret = AuthCredentialSource::from_env(var_name); + self + } + + /// Read a static access token from an environment variable. + /// If set and resolvable, this bypasses the client-credentials flow. + pub fn access_token_env(mut self, var_name: impl Into) -> Self { + self.access_token = AuthCredentialSource::from_env(var_name); + self + } + + /// Read a refresh token from an environment variable. + pub fn refresh_token_env(mut self, var_name: impl Into) -> Self { + self.refresh_token = AuthCredentialSource::from_env(var_name); + self + } + + /// Set a custom credential source for the client ID. + pub fn client_id_source(mut self, source: AuthCredentialSource) -> Self { + self.client_id = source; + self + } + + /// Set a custom credential source for the client secret. + pub fn client_secret_source(mut self, source: AuthCredentialSource) -> Self { + self.client_secret = source; + self + } + + /// Set a custom credential source for the access token. + pub fn access_token_source(mut self, source: AuthCredentialSource) -> Self { + self.access_token = source; + self + } + + /// Set a custom credential source for the refresh token. + pub fn refresh_token_source(mut self, source: AuthCredentialSource) -> Self { + self.refresh_token = source; + self + } + + /// Get the token URL, if set. + pub fn get_token_url(&self) -> Option<&str> { + self.token_url.as_deref() + } + + /// Get the client ID source. + pub fn get_client_id(&self) -> &AuthCredentialSource { + &self.client_id + } + + /// Get the client secret source. + pub fn get_client_secret(&self) -> &AuthCredentialSource { + &self.client_secret + } + + /// Get the access token source. + pub fn get_access_token(&self) -> &AuthCredentialSource { + &self.access_token + } + + /// Get the refresh token source. + pub fn get_refresh_token(&self) -> &AuthCredentialSource { + &self.refresh_token + } +} + +impl AuthSchemeBuilder for OAuth2Auth { + fn into_binding(self) -> (String, SchemeBinding) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bearer_auth_builds_token_binding() { + let (name, binding) = BearerAuth::new("bearerAuth") + .env("MY_TOKEN") + .into_binding(); + assert_eq!(name, "bearerAuth"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Env(ref e)) if e == "MY_TOKEN")); + } + + #[test] + fn api_key_auth_builds_token_binding() { + let (name, binding) = ApiKeyAuth::new("apiKey") + .env("API_KEY") + .into_binding(); + assert_eq!(name, "apiKey"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Env(ref e)) if e == "API_KEY")); + } + + #[test] + fn basic_auth_builds_basic_binding() { + let (name, binding) = BasicAuth::new("httpBasic") + .username_env("USER") + .password_env("PASS") + .into_binding(); + assert_eq!(name, "httpBasic"); + match binding { + SchemeBinding::Basic { username, password } => { + assert!(matches!(username, AuthCredentialSource::Env(ref e) if e == "USER")); + assert!(matches!(password, AuthCredentialSource::Env(ref e) if e == "PASS")); + } + _ => panic!("expected Basic binding"), + } + } + + #[test] + fn oauth2_auth_with_static_token() { + let (name, binding) = OAuth2Auth::new("OAuth2Security") + .access_token_env("MY_ACCESS_TOKEN") + .token_url("https://auth.example.com/token") + .into_binding(); + assert_eq!(name, "OAuth2Security"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Env(ref e)) if e == "MY_ACCESS_TOKEN")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + let (name, binding) = OAuth2Auth::new("OAuth2Security") + .client_id_env("CLIENT_ID") + .client_secret_env("CLIENT_SECRET") + .token_url("https://auth.example.com/token") + .into_binding(); + assert_eq!(name, "OAuth2Security"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/schemes.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/schemes.rs new file mode 100644 index 000000000000..db98d297271b --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/schemes.rs @@ -0,0 +1,433 @@ +//! Concrete auth-scheme providers: bearer tokens, HTTP basic, and arbitrary +//! header-bound credentials. Each one is a small wrapper around an +//! [`AuthCredentialSource`] that knows how to format the resolved value as +//! an outgoing header. +//! +//! # Secret-handling tradeoff +//! +//! Each `apply` formats the outgoing header by `expose_secret`-ing the +//! resolved [`SecretString`] into a transient `String` buffer (e.g. +//! `"Bearer " + token`). That buffer is not zeroized on drop. We accept the +//! transient unprotected copy because the `HeaderValue` it lowers into +//! (and the resulting on-the-wire `reqwest::Request` body) is not zeroized +//! either — adding zeroization here without doing it end-to-end would be +//! security theater. The mitigations still in force: `set_sensitive(true)` +//! on every produced `HeaderValue` so reqwest redacts it in `Debug`, and +//! `SecretString`'s redacting `Debug`/`Display` impl at the source. + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use secrecy::ExposeSecret; + +use crate::auth::credential::AuthCredentialSource; +use crate::auth::provider::{AuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// BearerAuthProvider — Authorization: Bearer +// --------------------------------------------------------------------------- + +/// `Authorization: Bearer ` (RFC 6750). +#[derive(Debug, Clone)] +pub struct BearerAuthProvider { + name: String, + token: AuthCredentialSource, +} + +impl BearerAuthProvider { + pub fn new(name: impl Into, token: AuthCredentialSource) -> Self { + Self { + name: name.into(), + token, + } + } +} + +impl AuthProvider for BearerAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.token.resolve().is_some() + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let Some(token) = self.token.resolve() else { + return Ok(request); + }; + // Avoid `RequestBuilder::bearer_auth` — it panics on tokens with + // bytes that can't be a HeaderValue (CTL chars, NUL, non-ASCII). + // AGENTS.md flags adversarial inputs explicitly. + let mut value = String::with_capacity(7 + token.expose_secret().len()); + value.push_str("Bearer "); + value.push_str(token.expose_secret()); + let mut header = reqwest::header::HeaderValue::from_str(&value) + .map_err(|e| CliError::Auth(format!("Invalid bearer token: {e}")))?; + header.set_sensitive(true); + Ok(request.header(reqwest::header::AUTHORIZATION, header)) + } +} + +// --------------------------------------------------------------------------- +// BasicAuthProvider — Authorization: Basic base64(user:pass) +// --------------------------------------------------------------------------- + +/// `Authorization: Basic base64(username:password)` (RFC 7617). +/// +/// Three construction modes: +/// +/// | Constructor | `has_credentials` requires | Omitted field sent as | +/// |---|---|---| +/// | [`new`](Self::new) | both username **and** password | — | +/// | [`username_only`](Self::username_only) | username | password = `""` | +/// | [`password_only`](Self::password_only) | password | username = `""` | +#[derive(Debug, Clone)] +pub struct BasicAuthProvider { + name: String, + username: AuthCredentialSource, + password: AuthCredentialSource, + mode: BasicAuthMode, +} + +/// Controls which credentials [`BasicAuthProvider`] requires. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BasicAuthMode { + /// Both username and password must resolve. + Full, + /// Only the username must resolve; password is sent as `""`. + UsernameOnly, + /// Only the password must resolve; username is sent as `""`. + PasswordOnly, +} + +impl BasicAuthProvider { + /// Standard HTTP Basic auth — both username and password are required. + pub fn new( + name: impl Into, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + Self { + name: name.into(), + username, + password, + mode: BasicAuthMode::Full, + } + } + + /// Username-only Basic auth (empty password). Common for APIs that + /// accept an API key as the HTTP Basic username. + pub fn username_only( + name: impl Into, + username: AuthCredentialSource, + ) -> Self { + Self { + name: name.into(), + username, + password: AuthCredentialSource::Missing, + mode: BasicAuthMode::UsernameOnly, + } + } + + /// Password-only Basic auth (empty username). Used by APIs that + /// expect the token in the password field of HTTP Basic. + pub fn password_only( + name: impl Into, + password: AuthCredentialSource, + ) -> Self { + Self { + name: name.into(), + username: AuthCredentialSource::Missing, + password, + mode: BasicAuthMode::PasswordOnly, + } + } +} + +impl AuthProvider for BasicAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + match self.mode { + BasicAuthMode::Full => { + self.username.resolve().is_some() && self.password.resolve().is_some() + } + BasicAuthMode::UsernameOnly => self.username.resolve().is_some(), + BasicAuthMode::PasswordOnly => self.password.resolve().is_some(), + } + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let u = self.username.resolve(); + let p = self.password.resolve(); + + // In Full mode both must be present; in partial modes the + // omitted half is sent as the empty string. + match self.mode { + BasicAuthMode::Full if u.is_none() || p.is_none() => return Ok(request), + BasicAuthMode::UsernameOnly if u.is_none() => return Ok(request), + BasicAuthMode::PasswordOnly if p.is_none() => return Ok(request), + _ => {} + } + + let u_ref = u.as_ref().map(|s| s.expose_secret()).unwrap_or(""); + let p_ref = p.as_ref().map(|s| s.expose_secret()).unwrap_or(""); + + let mut combined = String::with_capacity(u_ref.len() + 1 + p_ref.len()); + combined.push_str(u_ref); + combined.push(':'); + combined.push_str(p_ref); + let encoded = BASE64.encode(&combined); + let value = format!("Basic {encoded}"); + let mut header = + reqwest::header::HeaderValue::from_str(&value).map_err(|e| { + CliError::Auth(format!("Invalid basic-auth credentials: {e}")) + })?; + header.set_sensitive(true); + Ok(request.header(reqwest::header::AUTHORIZATION, header)) + } +} + +// --------------------------------------------------------------------------- +// HeaderAuthProvider — raw or bearer-prefixed token in a named header. +// --------------------------------------------------------------------------- + +/// Send the token verbatim in a named header. Used by APIs that pass the +/// raw token in `Authorization` (no `Bearer ` prefix) and any custom +/// `X-Api-Key` style scheme. +/// +/// If `bearer_prefix` is true, the value is prefixed with `Bearer ` — +/// equivalent to a [`BearerAuthProvider`] but on a non-`Authorization` +/// header. +#[derive(Debug, Clone)] +pub struct HeaderAuthProvider { + name: String, + header_name: String, + token: AuthCredentialSource, + bearer_prefix: bool, +} + +impl HeaderAuthProvider { + pub fn new( + name: impl Into, + header_name: impl Into, + token: AuthCredentialSource, + bearer_prefix: bool, + ) -> Self { + Self { + name: name.into(), + header_name: header_name.into(), + token, + bearer_prefix, + } + } +} + +impl AuthProvider for HeaderAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.token.resolve().is_some() + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let Some(token) = self.token.resolve() else { + return Ok(request); + }; + let value = if self.bearer_prefix { + let mut s = String::with_capacity(7 + token.expose_secret().len()); + s.push_str("Bearer "); + s.push_str(token.expose_secret()); + s + } else { + token.expose_secret().to_string() + }; + let mut header_value = + reqwest::header::HeaderValue::from_str(&value).map_err(|e| { + CliError::Auth(format!( + "Invalid token for header '{}': {e}", + self.header_name + )) + })?; + header_value.set_sensitive(true); + Ok(request.header(self.header_name.as_str(), header_value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::test_helpers::{auth_header, header, req}; + + // -------- BearerAuthProvider -------- + + #[tokio::test] + async fn bearer_provider_emits_authorization_bearer() { + let p = BearerAuthProvider::new("bearerAuth", AuthCredentialSource::literal("tok")); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn bearer_provider_no_token_is_noop() { + let p = BearerAuthProvider::new("bearerAuth", AuthCredentialSource::Missing); + assert!(!p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r), None); + } + + #[tokio::test] + async fn bearer_provider_rejects_invalid_token_bytes() { + // A token containing a newline is not a valid HeaderValue. + // We must error, not panic — adversarial inputs are called out in + // AGENTS.md. + let p = BearerAuthProvider::new( + "bearerAuth", + AuthCredentialSource::literal("bad\ntoken"), + ); + let err = p + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap_err(); + assert!(matches!(err, CliError::Auth(_))); + } + + // -------- BasicAuthProvider -------- + + #[tokio::test] + async fn basic_provider_emits_base64_authorization() { + let p = BasicAuthProvider::new( + "basicAuth", + AuthCredentialSource::literal("alice"), + AuthCredentialSource::literal("hunter2"), + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64("alice:hunter2") = "YWxpY2U6aHVudGVyMg==" + assert_eq!( + auth_header(r).as_deref(), + Some("Basic YWxpY2U6aHVudGVyMg=="), + ); + } + + #[test] + fn basic_provider_full_missing_password_is_no_credentials() { + let p = BasicAuthProvider::new( + "basicAuth", + AuthCredentialSource::literal("alice"), + AuthCredentialSource::Missing, + ); + assert!(!p.has_credentials()); + } + + #[test] + fn basic_provider_full_missing_username_is_no_credentials() { + let p = BasicAuthProvider::new( + "basicAuth", + AuthCredentialSource::Missing, + AuthCredentialSource::literal("pass"), + ); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn basic_provider_username_only_sends_empty_password() { + let p = BasicAuthProvider::username_only( + "basicAuth", + AuthCredentialSource::literal("api_key_123"), + ); + assert!(p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64("api_key_123:") — colon present, empty password + assert_eq!( + auth_header(r).as_deref(), + Some("Basic YXBpX2tleV8xMjM6"), + ); + } + + #[test] + fn basic_provider_username_only_missing_is_no_credentials() { + let p = BasicAuthProvider::username_only( + "basicAuth", + AuthCredentialSource::Missing, + ); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn basic_provider_password_only_sends_empty_username() { + let p = BasicAuthProvider::password_only( + "basicAuth", + AuthCredentialSource::literal("secret_token"), + ); + assert!(p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64(":secret_token") + assert_eq!( + auth_header(r).as_deref(), + Some("Basic OnNlY3JldF90b2tlbg=="), + ); + } + + #[test] + fn basic_provider_password_only_missing_is_no_credentials() { + let p = BasicAuthProvider::password_only( + "basicAuth", + AuthCredentialSource::Missing, + ); + assert!(!p.has_credentials()); + } + + // -------- HeaderAuthProvider -------- + + #[tokio::test] + async fn header_provider_raw_value_no_prefix() { + let p = HeaderAuthProvider::new( + "linearKey", + "Authorization", + AuthCredentialSource::literal("lin_api_xxx"), + false, + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("lin_api_xxx")); + } + + #[tokio::test] + async fn header_provider_bearer_prefix_named_header() { + let p = HeaderAuthProvider::new( + "squareKey", + "X-Auth", + AuthCredentialSource::literal("tok"), + true, + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-auth").as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn header_provider_custom_header_name() { + let p = HeaderAuthProvider::new( + "apiKey", + "X-Api-Key", + AuthCredentialSource::literal("k"), + false, + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("k")); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/auth/test_helpers.rs b/seed/cli/query-parameters-openapi/github-npm/src/auth/test_helpers.rs new file mode 100644 index 000000000000..e1b9dd6cb347 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/auth/test_helpers.rs @@ -0,0 +1,53 @@ +//! Shared test fixtures used across the `auth` submodules. Compiled only +//! under `#[cfg(test)]`. + +use std::sync::Arc; + +use crate::auth::credential::AuthCredentialSource; +use crate::auth::provider::DynAuthProvider; +use crate::auth::schemes::{BearerAuthProvider, HeaderAuthProvider}; + +/// A bare `RequestBuilder` pointing at example.com. Tests only inspect the +/// resulting headers — the URL doesn't matter. +pub fn req() -> reqwest::RequestBuilder { + reqwest::Client::new().post("https://example.com/") +} + +/// Read the `Authorization` header back off a built request, if present. +pub fn auth_header(req: reqwest::RequestBuilder) -> Option { + let built = req.build().unwrap(); + built + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(str::to_string) +} + +/// Read an arbitrary header value back off a built request. +pub fn header(req: reqwest::RequestBuilder, name: &str) -> Option { + let built = req.build().unwrap(); + built + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) +} + +/// Pre-built bearer provider with a literal token. Used as a fixture +/// for tests that need a credential-bearing provider. +pub fn bearer(name: &str, token: &str) -> DynAuthProvider { + Arc::new(BearerAuthProvider::new( + name, + AuthCredentialSource::literal(token), + )) +} + +/// Pre-built header provider — convenience for the apiKey-style tests. +pub fn api_key(name: &str, header_name: &str, value: &str) -> DynAuthProvider { + Arc::new(HeaderAuthProvider::new( + name, + header_name, + AuthCredentialSource::literal(value), + false, + )) +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/binding.rs b/seed/cli/query-parameters-openapi/github-npm/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/binding.rs @@ -0,0 +1,119 @@ +//! Binding trait — the async interface that protocol-specific adapters +//! (`OpenApiBinding`, `GraphqlBinding`) implement so the root [`CliApp`] +//! can compose them into a single CLI. +//! +//! [`CliApp`]: crate::app::CliApp + +use std::any::Any; +use std::future::Future; +use std::pin::Pin; + +use crate::auth::SchemeBinding; +use crate::error::CliError; + +/// A boxed future used by binding methods. +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// Outcome of a binding dispatch — either a decoded JSON value ready for +/// the root hook pipeline, or a signal that the binding handled output +/// itself (e.g. `--dry-run`, binary download, streaming). +pub enum DispatchResult { + /// A decoded response value. The root `CliApp` will run + /// `transform_response` / `recover_error` hooks and then format it. + Value(serde_json::Value), + /// The binding already wrote output (dry-run, streaming, file download). + /// The root `CliApp` skips its own formatting. + Handled, +} + +/// The async interface every protocol adapter must implement. +/// +/// A binding owns one logical API surface (one or more specs sharing +/// auth / transport config). The root `CliApp` holds +/// `Vec>` and delegates to the matched binding after +/// resolving which subcommand the user invoked. +pub trait Binding: Send + Sync { + /// Human-readable name for this binding (used in diagnostics). + fn name(&self) -> &str; + + /// Called by `CliApp::binding()` to propagate the CLI name to this + /// binding. HTTP config, logging env vars, and base-URL resolution + /// are CLI-level concerns that derive from this name. + fn set_cli_name(&mut self, name: &str); + + /// Build the `clap::Command` subtree contributed by this binding. + /// The root `CliApp` merges all binding trees into one CLI. + fn build_command(&self) -> Result; + + /// Execute the matched operation and return the decoded response. + /// + /// `root_matches` are the full parse result (for global flags). + /// `sub_matches` are scoped to the matched leaf subcommand. + /// `op_path` is the resolved command path (e.g. `["users", "get"]`). + fn dispatch<'a>( + &'a self, + root_matches: &'a clap::ArgMatches, + sub_matches: &'a clap::ArgMatches, + op_path: &'a [String], + ) -> BoxFuture<'a, Result>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// Return a type-erased binding context for use by CLI-level custom + /// command handlers. `matches` are the full parse result (needed + /// to resolve global flags like server vars and global headers). + /// + /// Returns `None` by default. Concrete bindings return their + /// protocol-specific `AppContext` (e.g. `openapi::AppContext`). + fn binding_context( + &self, + _matches: &clap::ArgMatches, + ) -> Result>, CliError> { + Ok(None) + } + + /// Receive root-level auth scheme bindings. Called by `CliApp` + /// before `build_command()` so the binding can incorporate root auth + /// into its command tree (help footer, global flags) and dispatch. + /// + /// Default: no-op. Bindings that support root-level auth override this. + fn set_root_auth(&mut self, _bindings: &[(String, SchemeBinding)]) {} + + /// Validate that all auth schemes referenced by the binding's spec + /// have a corresponding entry in the auth bindings. Returns `Ok(())` + /// if validation passes, or `Err(CliError::Validation(...))` listing + /// unregistered schemes. + /// + /// Default: no-op (passes). Concrete bindings override when they + /// can inspect their spec's security declarations. + fn validate_auth(&self) -> Result<(), CliError> { + Ok(()) + } + + /// Merge this binding's context into an existing context, or create + /// a new one if `existing` is `None`. + /// + /// When multiple bindings of the same protocol type are registered + /// on a `CliApp`, their contexts are merged so that custom command + /// handlers can access operations from any binding transparently. + /// + /// The default implementation delegates to [`binding_context`](Self::binding_context) + /// and ignores the existing context. + fn merge_binding_context( + &self, + matches: &clap::ArgMatches, + existing: Option>, + ) -> Result>, CliError> { + let _ = existing; + self.binding_context(matches) + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/cli_args.rs b/seed/cli/query-parameters-openapi/github-npm/src/cli_args.rs new file mode 100644 index 000000000000..54d5588496e2 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/cli_args.rs @@ -0,0 +1,352 @@ +//! CLI argument helpers shared across protocol modules. +//! +//! Pure functions that operate on raw `&[String]` args or `clap::ArgMatches` +//! and have no protocol-specific dependencies. + +use std::io::{IsTerminal, Read}; + +use crate::error::CliError; + +/// True for `--version`, `-V`, or the bare `version` subcommand. +pub fn is_version_flag(arg: &str) -> bool { + matches!(arg, "--version" | "-V" | "version") +} + +/// Resolve the API base URL override from the `--base-url` flag and the +/// `{NAME}_BASE_URL` env var (flag wins). Validates the flag value for +/// dangerous characters; the env var is treated as trusted. +pub fn resolve_base_url_override( + matches: &clap::ArgMatches, + app_name: &str, +) -> Result, CliError> { + let base_url_flag = matches.get_one::("base-url").cloned(); + if let Some(ref url) = base_url_flag { + crate::output::reject_dangerous_chars(url, "--base-url")?; + } + let env_var_name = format!("{}_BASE_URL", app_name.to_uppercase().replace('-', "_")); + let base_url_env_var = std::env::var(env_var_name).ok(); + Ok(base_url_flag.or(base_url_env_var)) +} + +/// Returns true when raw args contain both a help flag and `--format json`. +/// +/// Triggered before clap parses so agents can request machine-readable help via +/// `--help --format json` without clap intercepting. +pub fn wants_json_help(args: &[String]) -> bool { + let has_help = args.iter().any(|a| a == "--help" || a == "-h"); + let has_json_format = args.iter().enumerate().any(|(i, a)| { + a.eq_ignore_ascii_case("--format=json") + || (a == "--format" + && args.get(i + 1).map(|s| s.to_lowercase() == "json") == Some(true)) + }); + has_help && has_json_format +} + +/// Extracts the subcommand path from raw args (non-flag tokens after the binary +/// name). Skips global flags (and their values) that may appear before the +/// subcommand, so they don't terminate the `take_while(!starts_with('-'))` +/// scan that follows. +/// +/// Currently elided global flags: `--format ` (and its `--format=VALUE` +/// equals form). +/// +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +pub fn extract_subcommand_path(args: &[String]) -> Vec { + let mut skip_next = false; + args.iter() + .skip(1) // skip binary name + .filter(|a| { + if skip_next { + skip_next = false; + return false; + } + if a.as_str() == "--format" { + skip_next = true; + return false; + } + if a.starts_with("--format=") { + return false; + } + true + }) + .take_while(|a| !a.starts_with('-')) + .cloned() + .collect() +} + +/// True when the user invoked the bare `errors` subcommand. +/// +/// Matches only the exact two-argument form (` errors`) plus a +/// trailing `--format`/`-h`/`--help` global flag — keeping the surface +/// narrow so future user specs that define an `errors` group with +/// nested operations (e.g. `cli errors list`) are not silently +/// hijacked. The check happens before clap parses, so spec-driven +/// subcommands continue to dispatch normally. +/// +/// Format values (`json`, `yaml`, `table`, `csv`) are recognized only +/// immediately after `--format` (space-separated) or in the +/// `--format=` equals form. A bare `cli errors json` is NOT +/// intercepted — it falls through to clap so a user resource named +/// `json` remains reachable. +pub fn is_errors_subcommand(args: &[String]) -> bool { + if args.get(1).map(|s| s.as_str()) != Some("errors") { + return false; + } + // Allow only globally-recognized flags after the `errors` token so + // an `errors`-named API resource with positional subcommands like + // `errors list` is not hijacked. `--format`/`-h`/`--help` are the + // only flags this command honors (see `print_errors_table`); any + // other token defers to clap, which will return an "unrecognized + // subcommand" error or dispatch the user's resource as expected. + // + // Format values (json/yaml/table/csv) are accepted only when the + // previous token was `--format`; bare positional tokens like + // `cli errors json` fall through to clap. + let tail: Vec<&str> = args.iter().skip(2).map(|s| s.as_str()).collect(); + let mut i = 0; + while i < tail.len() { + let tok = tail[i]; + if tok == "--help" || tok == "-h" { + i += 1; + } else if tok == "--format" { + // Consume `--format` and its value (if present). + if let Some(next) = tail.get(i + 1) { + if is_format_value(next) { + i += 2; + } else { + // `--format` followed by an unrecognized value — + // not the errors subcommand. + return false; + } + } else { + // Trailing `--format` with no value — still recognized + // (print_errors falls back to the table format). + i += 1; + } + } else if let Some(rest) = tok.strip_prefix("--format=") { + if rest.is_empty() || is_format_value(rest) { + i += 1; + } else { + // `--format=banana` — unrecognized value; not the errors + // subcommand. + return false; + } + } else { + // Unknown positional or flag → user resource; defer to clap. + return false; + } + } + true +} + +/// Returns true for known `--format` values recognized by the `errors` +/// subcommand. +fn is_format_value(s: &str) -> bool { + s.eq_ignore_ascii_case("json") + || s.eq_ignore_ascii_case("yaml") + || s.eq_ignore_ascii_case("table") + || s.eq_ignore_ascii_case("csv") +} + +/// Read stdin to a string. Returns `Err` if stdin is a TTY or empty. +pub fn read_stdin_to_string() -> Result { + if std::io::stdin().is_terminal() { + return Err(CliError::Validation( + "stdin is a terminal; pipe data or redirect a file \ + (e.g. `cat data.json | cli cmd --json -`)" + .to_string(), + )); + } + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| CliError::Validation(format!("failed to read stdin: {e}")))?; + if buf.trim().is_empty() { + return Err(CliError::Validation( + "stdin was empty; `--json -` expects a JSON body to be piped on stdin" + .to_string(), + )); + } + Ok(buf) +} + +/// Resolve `--json` flag: `-` reads from stdin, else returns the literal. +pub fn resolve_body_json( + matched_args: &clap::ArgMatches, +) -> Result, CliError> { + let raw = matched_args + .try_get_one::("json") + .ok() + .flatten(); + match raw { + Some(s) if s == "-" => read_stdin_to_string().map(Some), + Some(s) => Ok(Some(s.clone())), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_is_version_flag() { + assert!(is_version_flag("--version")); + assert!(is_version_flag("-V")); + assert!(is_version_flag("version")); + assert!(!is_version_flag("--ver")); + } + + #[test] + fn test_wants_json_help_space_separated() { + assert!(wants_json_help(&args(&[ + "linear", "issues", "--help", "--format", "json", + ]))); + } + + #[test] + fn test_wants_json_help_equals() { + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); + } + + #[test] + fn test_wants_json_help_short_flag() { + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); + } + + #[test] + fn test_wants_json_help_case_insensitive() { + assert!(wants_json_help(&args(&[ + "linear", "--help", "--format", "JSON", + ]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); + } + + #[test] + fn test_no_json_help_without_format() { + assert!(!wants_json_help(&args(&["linear", "--help"]))); + } + + #[test] + fn test_no_json_help_without_help_flag() { + assert!(!wants_json_help(&args(&[ + "linear", "issues", "get", "--format", "json", + ]))); + } + + #[test] + fn test_extract_subcommand_path() { + assert_eq!( + extract_subcommand_path(&args(&[ + "linear", "issues", "get", "--help", "--format", "json", + ])), + vec!["issues", "get"], + ); + } + + #[test] + fn test_extract_subcommand_path_root() { + assert_eq!( + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), + Vec::::new(), + ); + } + + #[test] + fn test_extract_subcommand_path_format_before_subcommand() { + assert_eq!( + extract_subcommand_path(&args(&[ + "linear", "--format", "json", "issues", "--help", + ])), + vec!["issues"], + ); + } + + #[test] + fn test_extract_subcommand_path_format_equals_before_subcommand() { + assert_eq!( + extract_subcommand_path(&args(&[ + "linear", "--format=json", "issues", "get", "--help", + ])), + vec!["issues", "get"], + ); + } + + #[test] + fn test_is_errors_subcommand_positive() { + assert!(is_errors_subcommand(&args(&["cli", "errors"]))); + } + + #[test] + fn test_is_errors_subcommand_negative() { + assert!(!is_errors_subcommand(&args(&["cli", "get"]))); + assert!(!is_errors_subcommand(&args(&["cli"]))); + } + + #[test] + fn test_is_errors_subcommand_does_not_hijack_nested_resource() { + // If a user spec defines an `errors` resource with operations, + // `cli errors list` must defer to clap rather than print the + // exit codes table. + assert!(!is_errors_subcommand(&args(&["cli", "errors", "list"]))); + assert!(!is_errors_subcommand(&args(&["cli", "errors", "get", "123"]))); + } + + #[test] + fn test_is_errors_subcommand_allows_help_and_format_flags() { + assert!(is_errors_subcommand(&args(&["cli", "errors", "--help"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "-h"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format", "json"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format=json"]))); + } + + #[test] + fn test_is_errors_subcommand_rejects_unknown_flags() { + // Unknown flags after `errors` mean the user is targeting a + // spec-defined `errors` resource — defer to clap. + assert!(!is_errors_subcommand(&args(&["cli", "errors", "--json", "{}"]))); + assert!(!is_errors_subcommand(&args(&["cli", "errors", "--page-all"]))); + } + + #[test] + fn test_is_errors_subcommand_empty_args() { + assert!(!is_errors_subcommand(&args(&[]))); + } + + #[test] + fn test_is_errors_subcommand_bare_format_name_not_hijacked() { + // A bare `cli errors json` must NOT be intercepted — it should + // fall through to clap so a user resource named `json` is + // reachable. + assert!(!is_errors_subcommand(&args(&["cli", "errors", "json"]))); + assert!(!is_errors_subcommand(&args(&["cli", "errors", "yaml"]))); + assert!(!is_errors_subcommand(&args(&["cli", "errors", "table"]))); + assert!(!is_errors_subcommand(&args(&["cli", "errors", "csv"]))); + } + + #[test] + fn test_is_errors_subcommand_format_space_separated() { + // `--format json` (space-separated) must be recognized. + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format", "json"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format", "yaml"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format", "table"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format", "csv"]))); + } + + #[test] + fn test_is_errors_subcommand_format_equals() { + // `--format=json` (equals form) must be recognized. + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format=json"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format=yaml"]))); + } + + #[test] + fn test_is_errors_subcommand_default_no_format() { + // Plain `cli errors` with no format flag is still recognized. + assert!(is_errors_subcommand(&args(&["cli", "errors"]))); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/completions.rs b/seed/cli/query-parameters-openapi/github-npm/src/completions.rs new file mode 100644 index 000000000000..84cdeb37686f --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/completions.rs @@ -0,0 +1,175 @@ +//! Shell completion generation. +//! +//! Shared infrastructure for emitting shell completion scripts. Sits above +//! both protocol paths (`openapi/` and `graphql/`) and has no +//! protocol-specific dependencies. + +use clap::Command; +use clap_complete::{generate, Shell}; + +/// Returns `true` when `args` contains `"completion"` as the first +/// positional token (i.e. the subcommand position). This allows early +/// interception before normal API dispatch — avoiding collision with an +/// API resource that might also be named `completion`. +/// +/// Skips `--flag value` pairs so `box --base-url completion files` is +/// not mistaken for a completion request (`completion` there is the +/// value of `--base-url`, not a subcommand). Boolean flags like +/// `--dry-run` are recognised and do NOT consume the next token. +pub fn wants_completion(args: &[String]) -> bool { + crate::early_intercept::first_positional_is(args, "completion") +} + +/// Generate a shell completion script for `cmd` and write it to `writer`. +/// +/// `bin_name` is the name the user types to invoke the CLI (e.g. `"box"`). +/// The caller is responsible for building a `Command` that mirrors the full +/// CLI surface (subcommands, flags, etc.) so the generated script is complete. +/// +/// Returns an IO error if writing fails. +pub fn generate_completion_to(shell: Shell, cmd: &mut Command, bin_name: &str, writer: &mut dyn std::io::Write) -> std::io::Result<()> { + let mut buf = Vec::new(); + generate(shell, cmd, bin_name, &mut buf); + writer.write_all(&buf) +} + +/// Generate a shell completion script for `cmd` and write it to stdout. +/// +/// Thin wrapper around [`generate_completion_to`] that targets `stdout`. +pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { + generate_completion_to(shell, cmd, bin_name, &mut std::io::stdout()) +} + +/// Parse a shell name string into a [`Shell`] enum variant. +/// +/// Matching is case-sensitive, consistent with `clap_complete::Shell`'s +/// `FromStr` implementation and the `value_parser` on +/// [`completion_command`]. Returns `None` for unrecognized values +/// (including case mismatches like `"BASH"`). +pub fn parse_shell(s: &str) -> Option { + match s { + "bash" => Some(Shell::Bash), + "zsh" => Some(Shell::Zsh), + "fish" => Some(Shell::Fish), + "powershell" => Some(Shell::PowerShell), + "elvish" => Some(Shell::Elvish), + _ => None, + } +} + +/// Build the `completion` subcommand definition. Registered at the root +/// of the command tree so ` completion ` works. +pub fn completion_command() -> Command { + Command::new("completion") + .about("Generate shell completion scripts") + .arg_required_else_help(true) + .after_help( + "EXAMPLES:\n \ + # bash\n \ + completion bash > /etc/bash_completion.d/\n \ + # zsh\n \ + completion zsh > \"${fpath[1]}/_\"\n \ + # fish\n \ + completion fish > ~/.config/fish/completions/.fish", + ) + .arg( + clap::Arg::new("shell") + .required(true) + .value_parser(["bash", "zsh", "fish", "powershell", "elvish"]) + .help("Target shell (bash, zsh, fish, powershell, elvish)"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn wants_completion_detects_subcommand() { + assert!(wants_completion(&args(&["box", "completion", "bash"]))); + assert!(wants_completion(&args(&["box", "completion", "zsh"]))); + } + + #[test] + fn wants_completion_false_for_normal_commands() { + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); + } + + #[test] + fn wants_completion_false_when_nested() { + assert!(!wants_completion(&args(&[ + "box", "files", "completion", "bash" + ]))); + } + + #[test] + fn wants_completion_false_when_flag_value() { + assert!(!wants_completion(&args(&[ + "box", + "--base-url", + "completion", + "files", + ]))); + } + + #[test] + fn wants_completion_true_after_eq_flag() { + assert!(wants_completion(&args(&[ + "box", + "--base-url=http://localhost", + "completion", + "bash", + ]))); + } + + #[test] + fn wants_completion_with_boolean_flag() { + // --dry-run is a boolean flag (SetTrue) and must NOT consume the + // next token; "completion" is the subcommand, not the flag's value. + assert!(wants_completion(&args(&[ + "box", + "--dry-run", + "completion", + "bash", + ]))); + } + + #[test] + fn wants_completion_with_multiple_boolean_flags() { + assert!(wants_completion(&args(&[ + "box", + "--dry-run", + "--no-retry", + "completion", + "zsh", + ]))); + } + + #[test] + fn parse_shell_valid() { + assert_eq!(parse_shell("bash"), Some(Shell::Bash)); + assert_eq!(parse_shell("zsh"), Some(Shell::Zsh)); + assert_eq!(parse_shell("fish"), Some(Shell::Fish)); + assert_eq!(parse_shell("powershell"), Some(Shell::PowerShell)); + assert_eq!(parse_shell("elvish"), Some(Shell::Elvish)); + } + + #[test] + fn parse_shell_rejects_uppercase() { + // parse_shell must be case-sensitive, matching clap's value_parser. + assert_eq!(parse_shell("BASH"), None); + assert_eq!(parse_shell("Zsh"), None); + assert_eq!(parse_shell("FISH"), None); + } + + #[test] + fn parse_shell_invalid() { + assert_eq!(parse_shell("nushell"), None); + assert_eq!(parse_shell(""), None); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/custom_commands.rs b/seed/cli/query-parameters-openapi/github-npm/src/custom_commands.rs new file mode 100644 index 000000000000..17b5e7e25fbd --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/custom_commands.rs @@ -0,0 +1,298 @@ +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. +//! +//! Used by `app::CliApp::command()` / `command_under()` at the root +//! level. The free functions `graft_subcommand` and +//! `walk_matches_to_custom` are the public (crate-internal) API. + +/// Graft a custom `clap::Command` into an existing command tree under +/// `parent_path`. The leaf name is `cmd.get_name()`. +/// +/// Behavior: +/// - Walks down `parent_path` using `mut_subcommand`, recursively grafting. +/// - At any level where the named parent doesn't exist, creates it as a +/// bare subcommand so the path is reachable. +/// - At the leaf level, if a subcommand with the same name already exists +/// it is replaced by `cmd` (custom-wins on leaf collision). +pub fn graft_subcommand( + parent: clap::Command, + parent_path: &[String], + cmd: clap::Command, +) -> clap::Command { + if parent_path.is_empty() { + let leaf_name = cmd.get_name().to_string(); + if parent.find_subcommand(&leaf_name).is_some() { + parent.mut_subcommand(leaf_name, move |_existing| cmd) + } else { + parent.subcommand(cmd) + } + } else { + let head = parent_path[0].clone(); + let rest: Vec = parent_path[1..].to_vec(); + if parent.find_subcommand(&head).is_some() { + parent.mut_subcommand(head, move |sub| graft_subcommand(sub, &rest, cmd)) + } else { + let new_parent = clap::Command::new(head) + .subcommand_required(true) + .arg_required_else_help(true); + let new_parent = graft_subcommand(new_parent, &rest, cmd); + parent.subcommand(new_parent) + } + } +} + +/// Walk a parsed `ArgMatches` tree along `parent_path` and return the leaf +/// matches if the final subcommand equals `leaf_name`. Returns `None` if +/// any segment along the path doesn't match. +pub fn walk_matches_to_custom<'a>( + matches: &'a clap::ArgMatches, + parent_path: &[String], + leaf_name: &str, +) -> Option<&'a clap::ArgMatches> { + let mut current = matches; + for seg in parent_path { + let (name, sub) = current.subcommand()?; + if name != seg { + return None; + } + current = sub; + } + let (name, sub) = current.subcommand()?; + if name == leaf_name { + Some(sub) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::CliError; + + // ── Registry (test-only) ──────────────────────────────────────── + // + // `CustomCommandRegistry` was the old per-binding custom command + // system. Root `CliApp::command()` replaced it, but the struct is + // still useful for testing `graft_subcommand` / `walk_matches_to_custom`. + + type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; + type Entry = (Vec, clap::Command, HandlerFn); + + struct CustomCommandRegistry { + entries: Vec>, + } + + impl CustomCommandRegistry { + fn new() -> Self { + Self { entries: Vec::new() } + } + + fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { + self.register_under::<&str>(&[], cmd, handler); + } + + fn register_under>( + &mut self, + path: &[S], + cmd: clap::Command, + handler: HandlerFn, + ) { + let owned: Vec = path.iter().map(|s| s.as_ref().to_string()).collect(); + self.entries.push((owned, cmd, handler)); + } + + fn graft_into(&self, mut cli: clap::Command) -> clap::Command { + for (path, cmd, _) in &self.entries { + cli = graft_subcommand(cli, path, cmd.clone()); + } + cli + } + + fn dispatch( + &self, + matches: &clap::ArgMatches, + ctx: &C, + ) -> Option> { + for (path, cmd, handler) in &self.entries { + if let Some(target) = walk_matches_to_custom(matches, path, cmd.get_name()) { + return Some(handler(target, ctx)); + } + } + None + } + + fn len(&self) -> usize { + self.entries.len() + } + + fn entries(&self) -> &[Entry] { + &self.entries + } + } + + struct DummyCtx; + + fn dummy_handler(_m: &clap::ArgMatches, _c: &DummyCtx) -> Result<(), CliError> { + Ok(()) + } + + #[test] + fn graft_top_level_adds_command() { + let cli = clap::Command::new("root").subcommand(clap::Command::new("existing")); + let custom = clap::Command::new("custom"); + let grafted = graft_subcommand(cli, &[], custom); + assert!(grafted.find_subcommand("existing").is_some()); + assert!(grafted.find_subcommand("custom").is_some()); + } + + #[test] + fn graft_top_level_collision_replaces_leaf() { + let cli = clap::Command::new("root") + .subcommand(clap::Command::new("dup").about("from spec")); + let custom = clap::Command::new("dup").about("from custom"); + let grafted = graft_subcommand(cli, &[], custom); + let dup = grafted.find_subcommand("dup").unwrap(); + assert_eq!(dup.get_about().map(|s| s.to_string()).as_deref(), Some("from custom")); + } + + #[test] + fn graft_into_existing_parent_keeps_siblings() { + let webhooks = clap::Command::new("webhooks") + .subcommand(clap::Command::new("list")) + .subcommand(clap::Command::new("create")); + let cli = clap::Command::new("root").subcommand(webhooks); + + let verify = clap::Command::new("verify").about("custom"); + let grafted = graft_subcommand(cli, &["webhooks".to_string()], verify); + + let webhooks = grafted.find_subcommand("webhooks").unwrap(); + assert!(webhooks.find_subcommand("list").is_some()); + assert!(webhooks.find_subcommand("create").is_some()); + assert!(webhooks.find_subcommand("verify").is_some()); + } + + #[test] + fn graft_leaf_collision_under_parent_replaces() { + let webhooks = clap::Command::new("webhooks") + .subcommand(clap::Command::new("list").about("spec")); + let cli = clap::Command::new("root").subcommand(webhooks); + let custom_list = clap::Command::new("list").about("custom"); + let grafted = graft_subcommand(cli, &["webhooks".to_string()], custom_list); + let leaf = grafted + .find_subcommand("webhooks") + .unwrap() + .find_subcommand("list") + .unwrap(); + assert_eq!(leaf.get_about().map(|s| s.to_string()).as_deref(), Some("custom")); + } + + #[test] + fn graft_creates_missing_intermediate_parent() { + let cli = clap::Command::new("root"); + let leaf = clap::Command::new("verify"); + let grafted = graft_subcommand(cli, &["new-parent".to_string()], leaf); + let parent = grafted.find_subcommand("new-parent").unwrap(); + assert!(parent.find_subcommand("verify").is_some()); + } + + #[test] + fn walk_matches_finds_leaf() { + let cmd = clap::Command::new("root") + .subcommand(clap::Command::new("webhooks").subcommand(clap::Command::new("verify"))); + let matches = cmd.get_matches_from(vec!["root", "webhooks", "verify"]); + let result = walk_matches_to_custom(&matches, &["webhooks".to_string()], "verify"); + assert!(result.is_some()); + } + + #[test] + fn walk_matches_misses_when_path_diverges() { + let cmd = clap::Command::new("root") + .subcommand(clap::Command::new("webhooks").subcommand(clap::Command::new("list"))); + let matches = cmd.get_matches_from(vec!["root", "webhooks", "list"]); + let result = walk_matches_to_custom(&matches, &["webhooks".to_string()], "verify"); + assert!(result.is_none()); + } + + #[test] + fn walk_matches_misses_when_parent_diverges() { + let cmd = clap::Command::new("root") + .subcommand(clap::Command::new("other").subcommand(clap::Command::new("verify"))); + let matches = cmd.get_matches_from(vec!["root", "other", "verify"]); + let result = walk_matches_to_custom(&matches, &["webhooks".to_string()], "verify"); + assert!(result.is_none()); + } + + #[test] + fn registry_registers_top_level_command() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register(clap::Command::new("custom"), dummy_handler); + assert_eq!(reg.len(), 1); + assert!(reg.entries()[0].0.is_empty()); + assert_eq!(reg.entries()[0].1.get_name(), "custom"); + } + + #[test] + fn registry_registers_under_path() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register_under(&["webhooks"], clap::Command::new("verify"), dummy_handler); + assert_eq!(reg.len(), 1); + assert_eq!(reg.entries()[0].0, vec!["webhooks".to_string()]); + assert_eq!(reg.entries()[0].1.get_name(), "verify"); + } + + #[test] + fn registry_graft_into_grafts_all_entries() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register(clap::Command::new("alpha"), dummy_handler); + reg.register_under(&["webhooks"], clap::Command::new("verify"), dummy_handler); + + let cli = clap::Command::new("root"); + let grafted = reg.graft_into(cli); + + assert!(grafted.find_subcommand("alpha").is_some()); + let webhooks = grafted.find_subcommand("webhooks").unwrap(); + assert!(webhooks.find_subcommand("verify").is_some()); + } + + #[test] + fn registry_dispatch_invokes_matching_handler() { + use std::cell::Cell; + // Use thread-local state so the fn pointer (which can't capture) + // can record that it ran. + thread_local! { + static CALLED: Cell = const { Cell::new(false) }; + } + fn handler(_m: &clap::ArgMatches, _c: &DummyCtx) -> Result<(), CliError> { + CALLED.with(|c| c.set(true)); + Ok(()) + } + + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register_under(&["webhooks"], clap::Command::new("verify"), handler); + + let cli = clap::Command::new("root"); + let cli = reg.graft_into(cli); + let matches = cli.get_matches_from(vec!["root", "webhooks", "verify"]); + + let result = reg.dispatch(&matches, &DummyCtx); + assert!(result.is_some()); + assert!(result.unwrap().is_ok()); + assert!(CALLED.with(|c| c.get())); + } + + #[test] + fn registry_dispatch_returns_none_when_no_custom_invoked() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register_under(&["webhooks"], clap::Command::new("verify"), dummy_handler); + + // Build a tree that has both a custom and a non-custom path. + let cli = clap::Command::new("root") + .subcommand(clap::Command::new("other").subcommand(clap::Command::new("thing"))); + let cli = reg.graft_into(cli); + let matches = cli.get_matches_from(vec!["root", "other", "thing"]); + + let result = reg.dispatch(&matches, &DummyCtx); + assert!(result.is_none()); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/early_intercept.rs b/seed/cli/query-parameters-openapi/github-npm/src/early_intercept.rs new file mode 100644 index 000000000000..28a0d329319a --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/early_intercept.rs @@ -0,0 +1,185 @@ +//! Shared infrastructure for early-intercept subcommands (`completion`, `man`). +//! +//! These subcommands are intercepted *before* normal API dispatch so that +//! an API resource that happens to share the same name doesn't collide. +//! This module houses the constants and helpers shared by both intercept +//! paths. + +/// Long flag names (without the `--` prefix) that are boolean +/// (`action(SetTrue)`) and therefore do NOT consume the next token. +/// Kept in sync with the flags registered in `commands::build_cli`. +pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ + "dry-run", + "page-all", + "no-extract", + "no-retry", + "no-stream", + "help", +]; + +/// Returns `true` when `args` contains `target` as the first positional +/// token (i.e. the subcommand position). Skips `--flag value` pairs so +/// `box --base-url files` is not mistaken for the subcommand. +/// Boolean flags like `--dry-run` are recognised and do NOT consume the +/// next token. +pub(crate) fn first_positional_is(args: &[String], target: &str) -> bool { + let mut skip_next = false; + for arg in args.iter().skip(1) { + if skip_next { + skip_next = false; + continue; + } + if arg.starts_with('-') { + if arg.contains('=') { + // --flag=value — value is consumed inline, no skip. + continue; + } + // Strip leading dashes to get the bare name. + let bare = arg.trim_start_matches('-'); + if !BOOLEAN_FLAGS.contains(&bare) { + // Value-taking flag — next token is its argument. + skip_next = true; + } + continue; + } + return arg == target; + } + false +} + +/// Returns the n-th positional argument (0-indexed, ignoring argv[0]), +/// correctly skipping value-taking flags' arguments per [`BOOLEAN_FLAGS`]. +/// +/// This is the multi-positional generalization of [`first_positional_is`]: +/// `first_positional_is(args, target)` is equivalent to +/// `nth_positional(args, 0) == Some(target)`. +/// +/// Used by the completion early-intercept path to extract the shell name +/// (positional #1, since `completion` is positional #0) while correctly +/// skipping value-taking flag arguments like `--base-url `. +pub(crate) fn nth_positional(args: &[String], n: usize) -> Option<&str> { + let mut skip_next = false; + let mut count = 0; + for arg in args.iter().skip(1) { + if skip_next { + skip_next = false; + continue; + } + if arg.starts_with('-') { + if arg.contains('=') { + // --flag=value — value is consumed inline, no skip. + continue; + } + // Strip leading dashes to get the bare name. + let bare = arg.trim_start_matches('-'); + if !BOOLEAN_FLAGS.contains(&bare) { + // Value-taking flag — next token is its argument. + skip_next = true; + } + continue; + } + if count == n { + return Some(arg.as_str()); + } + count += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn first_positional_basic() { + assert!(first_positional_is(&args(&["box", "completion", "bash"]), "completion")); + assert!(first_positional_is(&args(&["box", "man"]), "man")); + } + + #[test] + fn first_positional_false_for_other_subcommand() { + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); + } + + #[test] + fn first_positional_false_when_flag_value() { + assert!(!first_positional_is( + &args(&["box", "--base-url", "man", "files"]), + "man", + )); + } + + #[test] + fn first_positional_true_after_eq_flag() { + assert!(first_positional_is( + &args(&["box", "--base-url=http://localhost", "man"]), + "man", + )); + } + + #[test] + fn first_positional_true_after_boolean_flag() { + assert!(first_positional_is( + &args(&["box", "--dry-run", "completion", "bash"]), + "completion", + )); + } + + #[test] + fn first_positional_true_after_multiple_boolean_flags() { + assert!(first_positional_is( + &args(&["box", "--dry-run", "--no-retry", "man"]), + "man", + )); + } + + // --- nth_positional --- + + #[test] + fn nth_positional_skips_value_flag() { + // `--base-url` is value-taking, so "X" is its argument, not a + // positional. "completion" is positional #0, "bash" is positional #1. + assert_eq!( + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), + Some("bash"), + ); + } + + #[test] + fn nth_positional_with_boolean_flag() { + // `--dry-run` is boolean, so "completion" is positional #0 and + // "bash" is positional #1. + assert_eq!( + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), + Some("bash"), + ); + } + + #[test] + fn nth_positional_out_of_range() { + assert_eq!( + nth_positional(&args(&["box", "completion", "bash"]), 5), + None, + ); + } + + #[test] + fn nth_positional_zeroth() { + assert_eq!( + nth_positional(&args(&["box", "completion", "bash"]), 0), + Some("completion"), + ); + } + + #[test] + fn nth_positional_eq_flag() { + assert_eq!( + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), + Some("bash"), + ); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/error.rs b/seed/cli/query-parameters-openapi/github-npm/src/error.rs new file mode 100644 index 000000000000..e2d010a9e1d4 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/error.rs @@ -0,0 +1,467 @@ +//! Structured Error Types +//! +//! Provides error types and structured JSON error output for the CLI. + +use serde_json::json; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CliError { + #[error("{message}")] + Api { + code: u16, + message: String, + reason: String, + }, + + #[error("{0}")] + Validation(String), + + #[error("{0}")] + Auth(String), + + #[error("{0}")] + Discovery(String), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + + +impl CliError { + pub const EXIT_CODE_API: i32 = 1; + pub const EXIT_CODE_AUTH: i32 = 2; + pub const EXIT_CODE_VALIDATION: i32 = 3; + pub const EXIT_CODE_DISCOVERY: i32 = 4; + pub const EXIT_CODE_OTHER: i32 = 5; + + /// Create a duplicate of this error for passing to hook callbacks + /// while retaining the original. `Other(anyhow::Error)` is + /// converted to its display string since `anyhow::Error` is not + /// `Clone`. + pub fn duplicate(&self) -> Self { + match self { + Self::Api { code, message, reason } => Self::Api { + code: *code, + message: message.clone(), + reason: reason.clone(), + }, + Self::Validation(msg) => Self::Validation(msg.clone()), + Self::Auth(msg) => Self::Auth(msg.clone()), + Self::Discovery(msg) => Self::Discovery(msg.clone()), + Self::Other(e) => Self::Other(anyhow::anyhow!("{e:#}")), + } + } + + pub fn exit_code(&self) -> i32 { + match self { + CliError::Api { .. } => Self::EXIT_CODE_API, + CliError::Auth(_) => Self::EXIT_CODE_AUTH, + CliError::Validation(_) => Self::EXIT_CODE_VALIDATION, + CliError::Discovery(_) => Self::EXIT_CODE_DISCOVERY, + CliError::Other(_) => Self::EXIT_CODE_OTHER, + } + } + + pub fn to_json(&self) -> serde_json::Value { + match self { + CliError::Api { + code, + message, + reason, + } => json!({ + "error": { + "code": code, + "message": message, + "reason": reason, + } + }), + CliError::Validation(msg) => json!({ + "error": { + "code": 400, + "message": msg, + "reason": "validationError", + } + }), + CliError::Auth(msg) => json!({ + "error": { + "code": 401, + "message": msg, + "reason": "authError", + } + }), + CliError::Discovery(msg) => json!({ + "error": { + "code": 500, + "message": msg, + "reason": "discoveryError", + } + }), + CliError::Other(e) => json!({ + "error": { + "code": 500, + "message": format!("{e:#}"), + "reason": "internalError", + } + }), + } + } +} + +use crate::output::{colorize, sanitize_for_terminal}; + +/// All documented exit codes with their human-readable descriptions. +pub const EXIT_CODE_TABLE: &[(i32, &str, &str)] = &[ + (CliError::EXIT_CODE_API, "api", "API returned a non-success HTTP status"), + (CliError::EXIT_CODE_AUTH, "auth", "Authentication failed or credentials missing"), + (CliError::EXIT_CODE_VALIDATION, "validation", "Invalid arguments or request body"), + (CliError::EXIT_CODE_DISCOVERY, "discovery", "Schema loading or endpoint resolution failed"), + (CliError::EXIT_CODE_OTHER, "other", "Unexpected internal error"), +]; + +/// Render all documented exit codes to stdout in the format requested +/// by the user's raw args. +/// +/// Honors `--format json` (and equivalents) so AI agents can consume a +/// machine-readable inventory of exit codes — the whole point of this +/// command for scripting workflows. Unknown `--format` values fall +/// back to the human-readable table, matching the resolver behavior +/// elsewhere in the CLI. +pub fn print_errors(args: &[String]) { + write_errors_to(args, &mut std::io::stdout()); +} + +/// Writer-parameterized variant of [`print_errors`]. +pub fn write_errors_to(args: &[String], out: &mut dyn std::io::Write) { + match detect_errors_format(args) { + ErrorsFormat::Json => write_errors_json_to(out), + ErrorsFormat::Table => write_errors_table_to(out), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ErrorsFormat { + Table, + Json, +} + +fn detect_errors_format(args: &[String]) -> ErrorsFormat { + for (i, a) in args.iter().enumerate() { + if let Some(rest) = a.strip_prefix("--format=") { + if rest.eq_ignore_ascii_case("json") { + return ErrorsFormat::Json; + } + } else if a == "--format" { + if let Some(next) = args.get(i + 1) { + if next.eq_ignore_ascii_case("json") { + return ErrorsFormat::Json; + } + } + } + } + ErrorsFormat::Table +} + +/// Print a human-readable table of all exit codes to stdout. +pub fn print_errors_table() { + write_errors_table_to(&mut std::io::stdout()); +} + +fn write_errors_table_to(out: &mut dyn std::io::Write) { + let _ = writeln!(out, "Exit codes:\n"); + let _ = writeln!(out, " {:<6} {:<14} DESCRIPTION", "CODE", "CATEGORY"); + let _ = writeln!(out, " {:<6} {:<14} ───────────────────────────────────────────", "──────", "──────────────"); + for &(code, category, description) in EXIT_CODE_TABLE { + let _ = writeln!(out, " {:<6} {:<14} {}", code, category, description); + } + let _ = writeln!(out); + let _ = writeln!(out, "Exit code 0 means success. Any non-zero code indicates an error."); +} + +/// Print all documented exit codes as a JSON array on stdout. +/// +/// Shape: +/// ```json +/// { +/// "exit_codes": [ +/// {"code": 0, "category": "success", "description": "..."}, +/// {"code": 1, "category": "api", "description": "..."}, +/// ... +/// ] +/// } +/// ``` +/// +/// Includes the implicit success code (0) so consumers see the full +/// matrix without having to special-case the success path. +pub fn print_errors_json() { + write_errors_json_to(&mut std::io::stdout()); +} + +fn write_errors_json_to(out: &mut dyn std::io::Write) { + let mut entries: Vec = Vec::with_capacity(EXIT_CODE_TABLE.len() + 1); + entries.push(json!({ + "code": 0, + "category": "success", + "description": "Command completed successfully", + })); + for &(code, category, description) in EXIT_CODE_TABLE { + entries.push(json!({ + "code": code, + "category": category, + "description": description, + })); + } + let doc = json!({ "exit_codes": entries }); + let _ = writeln!(out, "{}", serde_json::to_string_pretty(&doc).expect("static EXIT_CODE_TABLE always serializes")); +} + +fn error_label(err: &CliError) -> String { + match err { + CliError::Api { .. } => colorize("error[api]:", "31"), + CliError::Auth(_) => colorize("error[auth]:", "31"), + CliError::Validation(_) => colorize("error[validation]:", "33"), + CliError::Discovery(_) => colorize("error[discovery]:", "31"), + CliError::Other(_) => colorize("error:", "31"), + } +} + +pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { + let json = err.to_json(); + let _ = writeln!( + out, + "{}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + eprintln!( + "{} {}", + error_label(err), + sanitize_for_terminal(&err.to_string()) + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exit_codes_are_distinct() { + let codes = [ + CliError::EXIT_CODE_API, + CliError::EXIT_CODE_AUTH, + CliError::EXIT_CODE_VALIDATION, + CliError::EXIT_CODE_DISCOVERY, + CliError::EXIT_CODE_OTHER, + ]; + let unique: std::collections::HashSet = codes.iter().copied().collect(); + assert_eq!(unique.len(), codes.len()); + } + + #[test] + fn test_error_to_json_api() { + let err = CliError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "notFound".to_string(), + }; + let json = err.to_json(); + assert_eq!(json["error"]["code"], 404); + assert_eq!(json["error"]["message"], "Not Found"); + } + + #[test] + fn test_error_to_json_validation() { + let err = CliError::Validation("Invalid input".to_string()); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 400); + } + + #[test] + fn test_exit_codes_all_variants() { + assert_eq!( + CliError::Api { code: 404, message: String::new(), reason: String::new() }.exit_code(), + CliError::EXIT_CODE_API + ); + assert_eq!(CliError::Auth(String::new()).exit_code(), CliError::EXIT_CODE_AUTH); + assert_eq!(CliError::Validation(String::new()).exit_code(), CliError::EXIT_CODE_VALIDATION); + assert_eq!(CliError::Discovery(String::new()).exit_code(), CliError::EXIT_CODE_DISCOVERY); + assert_eq!( + CliError::Other(anyhow::anyhow!("oops")).exit_code(), + CliError::EXIT_CODE_OTHER + ); + } + + #[test] + fn test_to_json_auth() { + let err = CliError::Auth("bad creds".to_string()); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 401); + assert_eq!(json["error"]["reason"], "authError"); + } + + #[test] + fn test_to_json_discovery() { + let err = CliError::Discovery("spec not found".to_string()); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 500); + assert_eq!(json["error"]["reason"], "discoveryError"); + assert_eq!(json["error"]["message"], "spec not found"); + } + + #[test] + fn test_to_json_other() { + let err = CliError::Other(anyhow::anyhow!("something broke")); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 500); + assert_eq!(json["error"]["reason"], "internalError"); + } + + #[test] + fn test_print_error_json_all_variants_no_panic() { + print_error_json(&CliError::Api { + code: 500, + message: "oops".to_string(), + reason: "err".to_string(), + }); + print_error_json(&CliError::Validation("bad input".to_string())); + print_error_json(&CliError::Auth("no auth".to_string())); + print_error_json(&CliError::Discovery("no spec".to_string())); + print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); + } + + #[test] + fn test_duplicate_preserves_variant() { + let api = CliError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "notFound".to_string(), + }; + let dup = api.duplicate(); + assert_eq!(dup.exit_code(), CliError::EXIT_CODE_API); + assert_eq!(dup.to_json()["error"]["code"], 404); + + let val = CliError::Validation("bad".to_string()); + assert_eq!(val.duplicate().exit_code(), CliError::EXIT_CODE_VALIDATION); + + let auth = CliError::Auth("denied".to_string()); + assert_eq!(auth.duplicate().exit_code(), CliError::EXIT_CODE_AUTH); + + let disc = CliError::Discovery("missing".to_string()); + assert_eq!(disc.duplicate().exit_code(), CliError::EXIT_CODE_DISCOVERY); + + // Other(anyhow) preserves variant and exit code. + let other = CliError::Other(anyhow::anyhow!("anyhow msg")); + let dup_other = other.duplicate(); + assert_eq!(dup_other.exit_code(), CliError::EXIT_CODE_OTHER); + } + + #[test] + fn exit_code_table_covers_all_known_codes() { + let table_codes: std::collections::HashSet = + EXIT_CODE_TABLE.iter().map(|&(c, _, _)| c).collect(); + let expected = [ + CliError::EXIT_CODE_API, + CliError::EXIT_CODE_AUTH, + CliError::EXIT_CODE_VALIDATION, + CliError::EXIT_CODE_DISCOVERY, + CliError::EXIT_CODE_OTHER, + ]; + for code in expected { + assert!(table_codes.contains(&code), "EXIT_CODE_TABLE missing code {code}"); + } + } + + #[test] + fn exit_code_table_has_no_duplicates() { + let codes: Vec = EXIT_CODE_TABLE.iter().map(|&(c, _, _)| c).collect(); + let unique: std::collections::HashSet = codes.iter().copied().collect(); + assert_eq!(unique.len(), codes.len(), "EXIT_CODE_TABLE has duplicate codes"); + } + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn detect_errors_format_defaults_to_table() { + assert_eq!(detect_errors_format(&args(&["cli", "errors"])), ErrorsFormat::Table); + } + + #[test] + fn detect_errors_format_recognizes_json_space_separated() { + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format", "json"])), + ErrorsFormat::Json, + ); + } + + #[test] + fn detect_errors_format_recognizes_json_equals() { + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format=json"])), + ErrorsFormat::Json, + ); + } + + #[test] + fn detect_errors_format_case_insensitive() { + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format", "JSON"])), + ErrorsFormat::Json, + ); + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format=Json"])), + ErrorsFormat::Json, + ); + } + + #[test] + fn detect_errors_format_unknown_format_falls_back_to_table() { + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format", "yaml"])), + ErrorsFormat::Table, + ); + } + + #[test] + fn detect_errors_format_trailing_format_flag_with_no_value_is_table() { + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format"])), + ErrorsFormat::Table, + ); + } + + #[test] + fn print_errors_json_emits_expected_shape() { + // Smoke: the JSON payload parses cleanly and includes every + // documented exit code (plus the implicit 0). Captures the + // contract that AI agents consume. + let mut entries: Vec = Vec::with_capacity(EXIT_CODE_TABLE.len() + 1); + entries.push(json!({ + "code": 0, + "category": "success", + "description": "Command completed successfully", + })); + for &(code, category, description) in EXIT_CODE_TABLE { + entries.push(json!({ + "code": code, + "category": category, + "description": description, + })); + } + let payload = json!({ "exit_codes": entries }); + let arr = payload["exit_codes"].as_array().expect("exit_codes is array"); + assert_eq!(arr.len(), EXIT_CODE_TABLE.len() + 1); + assert_eq!(arr[0]["code"], 0); + let codes: std::collections::HashSet = arr + .iter() + .filter_map(|e| e["code"].as_i64()) + .collect(); + for &(code, _, _) in EXIT_CODE_TABLE { + assert!(codes.contains(&(code as i64)), "missing code {code}"); + } + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/formatter.rs b/seed/cli/query-parameters-openapi/github-npm/src/formatter.rs new file mode 100644 index 000000000000..24a6a39d0eaf --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/formatter.rs @@ -0,0 +1,944 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Output Formatting +//! +//! Transforms JSON API responses into human-readable formats (table, YAML, CSV). + +use serde_json::Value; +use std::fmt::Write; + +/// Color emission mode. +/// +/// Resolved from CLI flags and environment in [`OutputPipeline::from_matches`]. +/// `Auto` means "let the resolver decide based on TTY / `NO_COLOR` / `CI` / etc." +/// (Resolver is implemented in Step 2; for now `Auto` is just stored.) +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ColorMode { + #[default] + Auto, + Always, + Never, +} + +/// Errors that can occur while constructing or running the output pipeline. +#[derive(Debug, thiserror::Error)] +pub enum FormatError { + #[error("unknown output format: {0}")] + UnknownFormat(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +/// Composable output pipeline. +/// +/// Built once at dispatch time from CLI matches, then threaded through the +/// executor and applied per response (or per page during `--page-all`). +/// +/// In Step 1 it carries only `format` and `color_mode` and behaves identically +/// to the prior `&OutputFormat` threading. Later steps layer in field +/// projection, jq filtering, and template rendering. +#[derive(Debug, Clone, Default)] +pub struct OutputPipeline { + pub format: OutputFormat, + pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, +} + +impl OutputPipeline { + /// Build a pipeline from parsed CLI matches. + /// + /// Returns `Err(FormatError::UnknownFormat)` for unrecognised + /// `--format` values. Callers should map this into their error type + /// (e.g. `CliError::Validation`). + pub fn from_matches(matches: &clap::ArgMatches) -> Result { + let format = match matches.get_one::("format") { + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, + None => OutputFormat::default(), + }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); + Ok(Self { + format, + color_mode: ColorMode::Auto, + quiet, + }) + } + + /// Render `value` to `out`, appending a trailing newline. + /// + /// When `quiet` is set, this is a no-op — the value is silently discarded. + pub fn emit( + &self, + out: &mut W, + value: &Value, + paginated: bool, + is_first_page: bool, + ) -> Result<(), FormatError> { + if self.quiet { + return Ok(()); + } + let rendered = if paginated { + format_value_paginated(value, &self.format, is_first_page) + } else { + format_value(value, &self.format) + }; + writeln!(out, "{rendered}")?; + Ok(()) + } +} + +/// Supported output formats. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum OutputFormat { + /// Pretty-printed JSON (default). + #[default] + Json, + /// Aligned text table. + Table, + /// YAML. + Yaml, + /// Comma-separated values. + Csv, +} + +impl OutputFormat { + /// Parse from a string argument. + /// + /// Returns `Ok(format)` for known values, or `Err(unknown_value)` if the + /// string is not recognised. Call sites should warn the user on `Err` and + /// decide whether to fall back to JSON or surface an error. + pub fn parse(s: &str) -> Result { + match s.to_lowercase().as_str() { + "json" => Ok(Self::Json), + "table" => Ok(Self::Table), + "yaml" | "yml" => Ok(Self::Yaml), + "csv" => Ok(Self::Csv), + other => Err(other.to_string()), + } + } + + /// Parse from a string argument, falling back to JSON for unknown values. + /// + /// Prefer `parse()` at call sites where you want to surface a warning. + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Self { + Self::parse(s).unwrap_or(Self::Json) + } +} + +/// Format a JSON value according to the specified output format. +pub fn format_value(value: &Value, format: &OutputFormat) -> String { + match format { + OutputFormat::Json => serde_json::to_string_pretty(value).unwrap_or_default(), + OutputFormat::Table => format_table(value), + OutputFormat::Yaml => format_yaml(value), + OutputFormat::Csv => format_csv(value), + } +} + +/// Format a JSON value for a paginated page. +/// +/// When auto-paginating with `--page-all`, CSV and table formats should only +/// emit column headers on the **first** page so that each subsequent page +/// contains only data rows, making the combined output machine-parseable. +/// +/// For JSON the output is compact (one JSON object per line / NDJSON). +/// For YAML each page is prefixed with a `---` document separator so the +/// combined stream is a valid YAML multi-document file. +pub fn format_value_paginated(value: &Value, format: &OutputFormat, is_first_page: bool) -> String { + match format { + OutputFormat::Json => serde_json::to_string(value).unwrap_or_default(), + OutputFormat::Csv => format_csv_page(value, is_first_page), + OutputFormat::Table => format_table_page(value, is_first_page), + // Prefix every page with a YAML document separator so that the + // concatenated stream is parseable as a multi-document YAML file. + OutputFormat::Yaml => format!("---\n{}", format_yaml(value)), + } +} + +/// Extract a "data array" from a typical API list response. +/// APIs often return lists as `{ "collection": [...], "pagination": {...} }` +/// where the array key varies by resource type. +fn extract_items(value: &Value) -> Option<(&str, &Vec)> { + if let Value::Object(obj) = value { + for (key, val) in obj { + if key == "nextPageToken" || key == "kind" || key.starts_with('_') { + continue; + } + if let Value::Array(arr) = val { + if !arr.is_empty() { + return Some((key, arr)); + } + } + } + } + None +} + +fn format_table(value: &Value) -> String { + format_table_page(value, true) +} + +/// Recursively flatten a JSON object into `(dot.notation.key, string_value)` pairs. +/// +/// Nested objects become `parent.child` key names so that `--format table` can +/// render them as individual columns instead of raw JSON blobs. +fn flatten_object(obj: &serde_json::Map, prefix: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for (key, val) in obj { + let full_key = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + match val { + Value::Object(nested) => { + out.extend(flatten_object(nested, &full_key)); + } + _ => { + out.push((full_key, value_to_cell(val))); + } + } + } + out +} + +/// Format as a text table, optionally omitting the header row. +/// +/// Pass `emit_header = false` for continuation pages when using `--page-all` +/// so the combined terminal output doesn't repeat column names and separator +/// lines between pages. +fn format_table_page(value: &Value, emit_header: bool) -> String { + // Try to extract a list of items from standard API response + let items = extract_items(value); + + if let Some((_key, arr)) = items { + format_array_as_table(arr, emit_header) + } else if let Value::Array(arr) = value { + format_array_as_table(arr, emit_header) + } else if let Value::Object(obj) = value { + // Single object: key/value table — flatten nested objects first + let mut output = String::new(); + let flat = flatten_object(obj, ""); + let max_key_len = flat.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + for (key, val_str) in &flat { + let _ = writeln!(output, "{key:max_key_len$} {val_str}"); + } + output + } else { + value.to_string() + } +} + +fn format_array_as_table(arr: &[Value], emit_header: bool) -> String { + if arr.is_empty() { + return "(empty)\n".to_string(); + } + + // Flatten each row so nested objects become dot-notation columns. + let flat_rows: Vec> = arr + .iter() + .map(|item| match item { + Value::Object(obj) => flatten_object(obj, ""), + _ => vec![(String::new(), value_to_cell(item))], + }) + .collect(); + + // Collect all unique column names (preserving insertion order). + let mut columns: Vec = Vec::new(); + for row in &flat_rows { + for (key, _) in row { + if !columns.contains(key) { + columns.push(key.clone()); + } + } + } + + if columns.is_empty() { + // Array of non-objects + let mut output = String::new(); + for item in arr { + let _ = writeln!(output, "{}", value_to_cell(item)); + } + return output; + } + + // Build lookup: row_index -> column_name -> cell_value + let row_maps: Vec> = flat_rows + .iter() + .map(|pairs| { + pairs + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect() + }) + .collect(); + + // Calculate column widths (char-count, not byte-count). + let mut widths: Vec = columns.iter().map(|c| c.chars().count()).collect(); + let rows: Vec> = row_maps + .iter() + .map(|row| { + columns + .iter() + .enumerate() + .map(|(i, col)| { + let cell = row.get(col.as_str()).copied().unwrap_or("").to_string(); + let char_len = cell.chars().count(); + if char_len > widths[i] { + widths[i] = char_len; + } + // Cap column width at 60 chars + if widths[i] > 60 { + widths[i] = 60; + } + cell + }) + .collect() + }) + .collect(); + + let mut output = String::new(); + + if emit_header { + // Header + let header: Vec = columns + .iter() + .enumerate() + .map(|(i, c)| format!("{:width$}", c, width = widths[i])) + .collect(); + let _ = writeln!(output, "{}", header.join(" ")); + + // Separator + let sep: Vec = widths.iter().map(|w| "─".repeat(*w)).collect(); + let _ = writeln!(output, "{}", sep.join(" ")); + } + + // Rows — truncate by char count to avoid panicking on multi-byte UTF-8. + for row in &rows { + let cells: Vec = row + .iter() + .enumerate() + .map(|(i, c)| { + let char_len = c.chars().count(); + let truncated = if char_len > widths[i] { + // Safe char-boundary slice: take widths[i]-1 chars, then append ellipsis. + let truncated_str: String = c.chars().take(widths[i] - 1).collect(); + format!("{truncated_str}…") + } else { + c.clone() + }; + // Pad to column width (by char count) + let pad = widths[i].saturating_sub(truncated.chars().count()); + format!("{truncated}{}", " ".repeat(pad)) + }) + .collect(); + let _ = writeln!(output, "{}", cells.join(" ")); + } + + output +} + +fn format_yaml(value: &Value) -> String { + json_to_yaml(value, 0) +} + +fn json_to_yaml(value: &Value, indent: usize) -> String { + let prefix = " ".repeat(indent); + match value { + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => { + if s.contains('\n') { + // Genuine multi-line content: block scalar is the most readable choice. + format!( + "|\n{}", + s.lines() + .map(|l| format!("{prefix} {l}")) + .collect::>() + .join("\n") + ) + } else { + // Single-line strings: always double-quote so that characters like + // `#` (comment marker) and `:` (mapping indicator) are never + // misinterpreted by YAML parsers. Escape backslashes and double + // quotes to keep the output valid. + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") + } + } + Value::Array(arr) => { + if arr.is_empty() { + return "[]".to_string(); + } + let mut out = String::new(); + for item in arr { + let val_str = json_to_yaml(item, indent + 1); + let _ = write!(out, "\n{prefix}- {val_str}"); + } + out + } + Value::Object(obj) => { + if obj.is_empty() { + return "{}".to_string(); + } + let mut out = String::new(); + for (key, val) in obj { + match val { + Value::Object(_) | Value::Array(_) => { + let val_str = json_to_yaml(val, indent + 1); + let _ = write!(out, "\n{prefix}{key}:{val_str}"); + } + _ => { + let val_str = json_to_yaml(val, indent); + let _ = write!(out, "\n{prefix}{key}: {val_str}"); + } + } + } + out + } + } +} + +fn format_csv(value: &Value) -> String { + format_csv_page(value, true) +} + +/// Format as CSV, optionally omitting the header row. +/// +/// Pass `emit_header = false` for all pages after the first when using +/// `--page-all`, so the combined output has a single header line. +fn format_csv_page(value: &Value, emit_header: bool) -> String { + let items = extract_items(value); + + let arr = if let Some((_key, arr)) = items { + arr.as_slice() + } else if let Value::Array(arr) = value { + arr.as_slice() + } else { + // Single value — just output it + return value_to_cell(value); + }; + + if arr.is_empty() { + return String::new(); + } + + // Array of non-objects + if !arr.iter().any(|v| v.is_object()) { + let mut output = String::new(); + for item in arr { + if let Value::Array(inner) = item { + let cells: Vec = inner + .iter() + .map(|v| csv_escape(&value_to_cell(v))) + .collect(); + let _ = writeln!(output, "{}", cells.join(",")); + } else { + let _ = writeln!(output, "{}", csv_escape(&value_to_cell(item))); + } + } + return output; + } + + // Collect columns + let mut columns: Vec = Vec::new(); + for item in arr { + if let Value::Object(obj) = item { + for key in obj.keys() { + if !columns.contains(key) { + columns.push(key.clone()); + } + } + } + } + + let mut output = String::new(); + + // Header (omitted on continuation pages) + if emit_header { + let _ = writeln!(output, "{}", columns.join(",")); + } + + // Rows + for item in arr { + let cells: Vec = columns + .iter() + .map(|col| { + if let Value::Object(obj) = item { + csv_escape(&value_to_cell(obj.get(col).unwrap_or(&Value::Null))) + } else { + String::new() + } + }) + .collect(); + let _ = writeln!(output, "{}", cells.join(",")); + } + + output +} + +fn csv_escape(s: &str) -> String { + if s.contains(',') || s.contains('"') || s.contains('\n') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +fn value_to_cell(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::Array(arr) => { + let items: Vec = arr.iter().map(value_to_cell).collect(); + items.join(", ") + } + Value::Object(_) => serde_json::to_string(value).unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_output_format_from_str() { + assert_eq!(OutputFormat::from_str("json"), OutputFormat::Json); + assert_eq!(OutputFormat::from_str("table"), OutputFormat::Table); + assert_eq!(OutputFormat::from_str("yaml"), OutputFormat::Yaml); + assert_eq!(OutputFormat::from_str("yml"), OutputFormat::Yaml); + assert_eq!(OutputFormat::from_str("csv"), OutputFormat::Csv); + assert_eq!(OutputFormat::from_str("unknown"), OutputFormat::Json); + } + + #[test] + fn test_output_format_parse_known() { + assert_eq!(OutputFormat::parse("json"), Ok(OutputFormat::Json)); + assert_eq!(OutputFormat::parse("table"), Ok(OutputFormat::Table)); + assert_eq!(OutputFormat::parse("yaml"), Ok(OutputFormat::Yaml)); + assert_eq!(OutputFormat::parse("yml"), Ok(OutputFormat::Yaml)); + assert_eq!(OutputFormat::parse("csv"), Ok(OutputFormat::Csv)); + // Case-insensitive + assert_eq!(OutputFormat::parse("JSON"), Ok(OutputFormat::Json)); + assert_eq!(OutputFormat::parse("TABLE"), Ok(OutputFormat::Table)); + } + + #[test] + fn test_output_format_parse_unknown_returns_err() { + assert!(OutputFormat::parse("bogus").is_err()); + assert_eq!(OutputFormat::parse("bogus").unwrap_err(), "bogus"); + assert!(OutputFormat::parse("").is_err()); + } + + #[test] + fn test_format_json() { + let val = json!({"name": "test"}); + let output = format_value(&val, &OutputFormat::Json); + assert!(output.contains("\"name\"")); + assert!(output.contains("\"test\"")); + } + + #[test] + fn test_format_table_array_of_objects() { + let val = json!({ + "files": [ + {"id": "1", "name": "hello.txt"}, + {"id": "2", "name": "world.txt"} + ] + }); + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("id")); + assert!(output.contains("name")); + assert!(output.contains("hello.txt")); + assert!(output.contains("world.txt")); + // Check separator line + assert!(output.contains("──")); + } + + #[test] + fn test_format_table_single_object() { + let val = json!({"id": "abc", "name": "test"}); + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("id")); + assert!(output.contains("abc")); + } + + #[test] + fn test_format_table_nested_object_flattened() { + // Nested objects should become dot-notation columns, not raw JSON blobs. + let val = json!({ + "user": { + "displayName": "Alice", + "emailAddress": "alice@example.com" + }, + "storageQuota": { + "limit": "1000", + "usage": "500" + } + }); + let output = format_value(&val, &OutputFormat::Table); + // Should contain dot-notation keys + assert!( + output.contains("user.displayName"), + "expected flattened key in output:\n{output}" + ); + assert!( + output.contains("user.emailAddress"), + "expected flattened key in output:\n{output}" + ); + assert!( + output.contains("Alice"), + "expected value in output:\n{output}" + ); + // Should NOT contain raw JSON blobs + assert!( + !output.contains("{\"displayName"), + "should not have raw JSON blob:\n{output}" + ); + } + + #[test] + fn test_format_table_nested_objects_in_array() { + let val = json!([ + {"id": "1", "owner": {"name": "Alice"}}, + {"id": "2", "owner": {"name": "Bob"}} + ]); + let output = format_value(&val, &OutputFormat::Table); + assert!( + output.contains("owner.name"), + "expected flattened column:\n{output}" + ); + assert!(output.contains("Alice"), "expected value:\n{output}"); + assert!(output.contains("Bob"), "expected value:\n{output}"); + } + + #[test] + fn test_format_table_multibyte_truncation_does_not_panic() { + // Column width cap is 60 chars, so a long string with multi-byte chars + // must be safely truncated without a byte-boundary panic. + let long_emoji = "😀".repeat(70); // each emoji is 4 bytes + let val = json!([{"col": long_emoji}]); + // Should not panic + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("col"), "column name must appear:\n{output}"); + } + + #[test] + fn test_format_table_multibyte_exact_boundary() { + // Multi-byte chars at various positions must not panic or produce garbled output. + let val = json!([{"name": "café résumé naïve"}]); + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("name"), "column must appear:\n{output}"); + } + + #[test] + fn test_format_csv() { + let val = json!({ + "files": [ + {"id": "1", "name": "hello"}, + {"id": "2", "name": "world"} + ] + }); + let output = format_value(&val, &OutputFormat::Csv); + assert!(output.contains("id,name")); + assert!(output.contains("1,hello")); + assert!(output.contains("2,world")); + } + + #[test] + fn test_format_csv_array_of_arrays() { + // Sheets API returns {"values": [["col1","col2"], ["a","b"]]} + let val = json!({ + "values": [ + ["Student Name", "Gender", "Class Level"], + ["Alexandra", "Female", "4. Senior"], + ["Andrew", "Male", "1. Freshman"] + ] + }); + let output = format_value(&val, &OutputFormat::Csv); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines[0], "Student Name,Gender,Class Level"); + assert_eq!(lines[1], "Alexandra,Female,4. Senior"); + assert_eq!(lines[2], "Andrew,Male,1. Freshman"); + } + + #[test] + fn test_format_csv_flat_scalars() { + // Flat array of non-object, non-array values → one value per line + let val = json!(["apple", "banana", "cherry"]); + let output = format_value(&val, &OutputFormat::Csv); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "apple"); + assert_eq!(lines[1], "banana"); + assert_eq!(lines[2], "cherry"); + } + + #[test] + fn test_format_csv_flat_scalars_with_escaping() { + // Scalars that contain commas/quotes must be CSV-escaped + let val = json!(["plain", "has,comma", "has\"quote"]); + let output = format_value(&val, &OutputFormat::Csv); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "plain"); + assert_eq!(lines[1], "\"has,comma\""); + assert_eq!(lines[2], "\"has\"\"quote\""); + } + + #[test] + fn test_format_csv_escape() { + assert_eq!(csv_escape("simple"), "simple"); + assert_eq!(csv_escape("has,comma"), "\"has,comma\""); + assert_eq!(csv_escape("has\"quote"), "\"has\"\"quote\""); + } + + #[test] + fn test_format_yaml() { + let val = json!({"name": "test", "count": 42}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!(output.contains("name: \"test\"")); + assert!(output.contains("count: 42")); + } + + #[test] + fn test_format_table_empty_array() { + let val = json!({"files": []}); + // No items to extract, falls back to single-object table + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("files")); + } + + #[test] + fn test_extract_items() { + let val = json!({"files": [{"id": "1"}], "nextPageToken": "abc"}); + let (key, items) = extract_items(&val).unwrap(); + assert_eq!(key, "files"); + assert_eq!(items.len(), 1); + } + + #[test] + fn test_extract_items_none() { + let val = json!({"status": "ok"}); + assert!(extract_items(&val).is_none()); + } + + // --- YAML block-scalar regression tests --- + + #[test] + fn test_format_yaml_hash_in_string_is_quoted_not_block() { + // `drive#file` contains `#` which is a YAML comment marker; the + // serialiser must quote it rather than emit a block scalar. + let val = json!({"kind": "drive#file", "id": "123"}); + let output = format_value(&val, &OutputFormat::Yaml); + // Must be a double-quoted string, not a block scalar (`|`). + assert!( + output.contains("kind: \"drive#file\""), + "expected double-quoted kind, got:\n{output}" + ); + assert!( + !output.contains("kind: |"), + "kind must not use block scalar, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_colon_in_string_is_quoted() { + let val = json!({"url": "https://example.com/path"}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!( + output.contains("url: \"https://example.com/path\""), + "expected double-quoted url, got:\n{output}" + ); + assert!(!output.contains("url: |"), "url must not use block scalar"); + } + + #[test] + fn test_format_yaml_multiline_still_uses_block() { + let val = json!({"body": "line one\nline two"}); + let output = format_value(&val, &OutputFormat::Yaml); + // Multi-line content should still use block scalar. + assert!( + output.contains("body: |"), + "multiline string must use block scalar, got:\n{output}" + ); + } + + // --- Paginated format tests --- + + #[test] + fn test_format_value_paginated_csv_first_page_has_header() { + let val = json!({ + "files": [ + {"id": "1", "name": "a.txt"}, + {"id": "2", "name": "b.txt"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Csv, true); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines[0], "id,name", "first page must start with header"); + assert_eq!(lines[1], "1,a.txt"); + } + + #[test] + fn test_format_value_paginated_csv_continuation_no_header() { + let val = json!({ + "files": [ + {"id": "3", "name": "c.txt"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Csv, false); + let lines: Vec<&str> = output.lines().collect(); + // The first (and only) line must be a data row, not the header. + assert_eq!(lines[0], "3,c.txt", "continuation page must have no header"); + assert!( + !output.contains("id,name"), + "header must be absent on continuation pages" + ); + } + + #[test] + fn test_format_value_paginated_table_first_page_has_header() { + let val = json!({ + "items": [ + {"id": "1", "name": "foo"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Table, true); + assert!( + output.contains("id"), + "table header must appear on first page" + ); + assert!(output.contains("──"), "separator must appear on first page"); + } + + #[test] + fn test_format_value_paginated_table_continuation_no_header() { + let val = json!({ + "items": [ + {"id": "2", "name": "bar"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Table, false); + assert!(output.contains("bar"), "data row must be present"); + assert!( + !output.contains("──"), + "separator must be absent on continuation pages" + ); + } + + #[test] + fn test_format_value_paginated_yaml_has_document_separator() { + let val = json!({"files": [{"id": "1", "name": "foo"}]}); + let first = format_value_paginated(&val, &OutputFormat::Yaml, true); + let second = format_value_paginated(&val, &OutputFormat::Yaml, false); + assert!( + first.starts_with("---\n"), + "first YAML page must start with ---" + ); + assert!( + second.starts_with("---\n"), + "continuation YAML pages must also start with ---" + ); + } + + // ----------------------------------------------------------------------- + // OutputPipeline (Step 1: abstraction only — format + color_mode) + // ----------------------------------------------------------------------- + + fn matches_for(args: &[&str]) -> clap::ArgMatches { + clap::Command::new("test") + .arg( + clap::Arg::new("format") + .long("format") + .value_name("FORMAT"), + ) + .try_get_matches_from(args) + .expect("clap parse should succeed in tests") + } + + #[test] + fn pipeline_from_matches_defaults_to_json_auto() { + let matches = matches_for(&["test"]); + let pipeline = OutputPipeline::from_matches(&matches).unwrap(); + assert_eq!(pipeline.format, OutputFormat::Json); + assert_eq!(pipeline.color_mode, ColorMode::Auto); + } + + #[test] + fn pipeline_from_matches_reads_explicit_format() { + let matches = matches_for(&["test", "--format", "yaml"]); + let pipeline = OutputPipeline::from_matches(&matches).unwrap(); + assert_eq!(pipeline.format, OutputFormat::Yaml); + } + + #[test] + fn pipeline_from_matches_rejects_unknown_format() { + let matches = matches_for(&["test", "--format", "garbage"]); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); + } + + #[test] + fn pipeline_emit_single_page_json_is_pretty_with_trailing_newline() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + quiet: false, + }; + let val = json!({"name": "test", "n": 1}); + let mut buf: Vec = Vec::new(); + pipeline.emit(&mut buf, &val, false, true).unwrap(); + let s = String::from_utf8(buf).unwrap(); + // pretty JSON spans multiple lines + assert!(s.contains("\"name\": \"test\""), "expected pretty JSON, got: {s}"); + assert!(s.contains('\n'), "expected indented (multi-line) JSON"); + assert!(s.ends_with('\n'), "expected trailing newline"); + } + + #[test] + fn pipeline_emit_paginated_json_is_compact_one_line() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + quiet: false, + }; + let val = json!({"name": "test", "n": 1}); + let mut buf: Vec = Vec::new(); + pipeline.emit(&mut buf, &val, true, true).unwrap(); + let s = String::from_utf8(buf).unwrap(); + // compact form: exactly one newline (the trailing one); no pretty + // indentation; suitable for NDJSON. + let body = s.strip_suffix('\n').expect("trailing newline"); + assert!(!body.contains('\n'), "expected single-line NDJSON, got: {s}"); + assert!(!body.contains(" "), "expected no indentation, got: {s}"); + assert!(body.contains("\"name\":\"test\""), "expected compact JSON, got: {s}"); + } + + #[test] + fn pipeline_emit_quiet_suppresses_output() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + quiet: true, + }; + let val = json!({"name": "test"}); + let mut buf: Vec = Vec::new(); + pipeline.emit(&mut buf, &val, false, true).unwrap(); + assert!(buf.is_empty(), "quiet mode should suppress all output"); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/graphql/app.rs b/seed/cli/query-parameters-openapi/github-npm/src/graphql/app.rs new file mode 100644 index 000000000000..b04c4a6cf262 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/graphql/app.rs @@ -0,0 +1,482 @@ +//! High-level API for building CLIs from GraphQL schemas. +//! +//! [`CliApp`] provides a builder-style API that lets consumers create a +//! fully-functional CLI in just a few lines. [`AppContext`] exposes the +//! loaded spec and executor so that custom command handlers can call the +//! API programmatically. + +use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; +use crate::error::CliError; +use crate::formatter; +use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; +use crate::graphql::executor; + +/// Builder for a schema-driven CLI application (GraphQL). +pub struct CliApp { + pub(crate) name: String, + pub(crate) spec_json: Option, + pub(crate) endpoint_url: Option, + /// Auth bindings; mirrors the OpenAPI variant. GraphQL introspection + /// JSON doesn't carry per-operation security metadata, so the + /// constructed provider is `Any` by default — generators can flip + /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that + /// require multiple schemes simultaneously. + pub(crate) auth_bindings: Vec<(String, SchemeBinding)>, + auth_strategy: AuthStrategy, + /// Trust roots parsed at builder-call time. Storing parsed certs (not + /// raw bytes) means the validation error message lives in one place + /// — at the call site of `extra_root_cert`, where it's most useful. + pub(crate) extra_root_certs: Vec, + /// Raw PEM bytes for each trust root added via `extra_root_cert`, kept + /// alongside the parsed `extra_root_certs` above. Threaded through to + /// `HttpConfig::with_parsed_root_certs` so transport-neutral callers + /// (`HttpConfig::resolve`) can hand PEM to non-reqwest TLS connectors. + pub(crate) extra_root_certs_pem: Vec>, +} + +#[allow(dead_code)] // Methods available for binding wrappers to delegate to. +impl CliApp { + /// Create a new CLI application with the given binary name. + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + spec_json: None, + endpoint_url: None, + auth_bindings: Vec::new(), + auth_strategy: AuthStrategy::Auto, + extra_root_certs: Vec::new(), + extra_root_certs_pem: Vec::new(), + } + } + + /// Set the GraphQL introspection JSON schema string. Typically used with `include_str!`. + pub fn spec(mut self, json: &str) -> Self { + self.spec_json = Some(json.to_string()); + self + } + + /// Set the GraphQL endpoint URL. + pub fn endpoint(mut self, url: &str) -> Self { + self.endpoint_url = Some(url.to_string()); + self + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::from_env(env))`. + pub fn auth_scheme_env(self, scheme_name: &str, env_var: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::from_env(env_var)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::cli(arg_name))`. + /// Auto-registers a global `--` flag at run time. + pub fn auth_scheme_cli(self, scheme_name: &str, arg_name: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::cli(arg_name)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::file(path))`. + pub fn auth_scheme_file(self, scheme_name: &str, path: impl AsRef) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::file(path)) + } + + /// Bind a credential source to a named auth scheme. See + /// [`crate::openapi::CliApp::auth_scheme`] for the OpenAPI version's + /// detailed semantics — the GraphQL variant differs only in that there + /// is no spec-declared scheme metadata, so single-value bindings always + /// produce an `Authorization: Bearer ` provider. + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.auth_bindings + .push((scheme_name.to_string(), SchemeBinding::Token(source))); + self + } + + /// Bind separate username and password sources to a basic-auth scheme. + pub fn auth_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Basic { username, password }, + )); + self + } + + /// Plug in a fully-custom [`AuthProvider`][crate::auth::AuthProvider] for + /// a scheme name. Wraps the provider in [`Arc`] internally; use + /// [`auth_provider_shared`](Self::auth_provider_shared) if you already + /// have a `DynAuthProvider`. + pub fn auth_provider

(self, scheme_name: &str, provider: P) -> Self + where + P: crate::auth::AuthProvider + 'static, + { + self.auth_provider_shared(scheme_name, std::sync::Arc::new(provider)) + } + + /// Variant of [`auth_provider`](Self::auth_provider) that takes an + /// already-built [`DynAuthProvider`]. + pub fn auth_provider_shared( + mut self, + scheme_name: &str, + provider: DynAuthProvider, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Custom(provider), + )); + self + } + + /// Pin how the bound auth schemes compose. See + /// [`crate::openapi::CliApp::auth_strategy`] for details. GraphQL has + /// no per-endpoint security metadata, so [`AuthStrategy::Routing`] + /// degenerates to `Any` here. + pub fn auth_strategy(mut self, strategy: AuthStrategy) -> Self { + self.auth_strategy = strategy; + self + } + + /// Register an extra trust root that this CLI will accept on top of the + /// system's default roots. `pem` must be a PEM-encoded certificate (or + /// concatenated PEM bundle), typically loaded with `include_bytes!`. + /// + /// Useful for distributing a CLI inside an organization where every + /// machine should trust the company's internal CA out of the box, without + /// asking each user to set `_CA_BUNDLE`. + /// + /// ```ignore + /// # // ignored: needs a real PEM file at the include path. + /// CliApp::new("internal-tool") + /// .spec(include_str!("schema.json")) + /// .endpoint("https://internal.example.com/graphql") + /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) + /// .run() + /// ``` + /// + /// Panics if the bytes don't parse as PEM, or if the PEM contains no + /// certificates. Failing fast at startup is preferable to silently + /// shipping a CLI that ignores its bundled cert. + pub fn extra_root_cert(mut self, pem: &[u8]) -> Self { + // Share the validation path with `HttpConfig::with_extra_root_cert` + // so error wording stays in sync between the panicking builder API + // and the Result-returning lower-level API. + let certs = crate::http::parse_extra_root_cert(pem) + .unwrap_or_else(|e| panic!("CliApp::extra_root_cert: {e}")); + self.extra_root_certs.extend(certs); + self.extra_root_certs_pem.push(pem.to_vec()); + self + } + + /// Decorate a clap `Command` with the auth help section. + /// Called from `GraphqlBinding::build_command()`. + pub(crate) fn decorate_command(&self, mut cli: clap::Command) -> clap::Command { + let existing_after_help = cli.get_after_help().map(|s| s.to_string()); + let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + if existing_after_help.is_some() || auth_section.is_some() { + let mut sections: Vec<&str> = Vec::with_capacity(2); + if let Some(ref s) = existing_after_help { + sections.push(s); + } + if let Some(ref s) = auth_section { + sections.push(s); + } + cli = cli.after_help(sections.join("\n\n")); + } + cli + } + + + /// Construct the [`DynAuthProvider`] used for this run from the + /// registered bindings. GraphQL has no spec-declared schemes; with no + /// bindings, returns a `NoAuthProvider`. + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { + crate::auth::build_provider_with_strategy( + &self.auth_bindings, + &std::collections::HashMap::new(), + self.auth_strategy, + false, + ) + } + + /// Build an auth provider from externally-finalized bindings. + /// Used by `GraphqlBinding::dispatch` after CLI-bound auth sources + /// have been resolved against the parsed clap matches. + pub(crate) fn build_auth_provider_from_finalized( + &self, + finalized: &[(String, crate::auth::SchemeBinding)], + ) -> DynAuthProvider { + crate::auth::build_provider_with_strategy( + finalized, + &std::collections::HashMap::new(), + self.auth_strategy, + false, + ) + } +} + +/// One binding's worth of prepared state inside an [`AppContext`]. +pub(crate) struct BindingEntry { + pub(crate) doc: RestDescription, + pub(crate) auth_provider: DynAuthProvider, + pub(crate) http_config: crate::http::HttpConfig, +} + +/// Runtime context passed to custom command handlers. +/// +/// Provides access to the loaded API spec(s) and the constructed auth +/// provider(s). When multiple `GraphqlBinding`s are registered, +/// method lookups and execution are automatically routed to the +/// binding that owns the target method. +pub struct AppContext { + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: bool, +} + +impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + self + } + + /// Add another binding's prepared state to this context. + pub(crate) fn add_entry(&mut self, entry: BindingEntry) { + self.entries.push(entry); + } + + /// Find which entry owns `method` by pointer identity. + fn entry_for_method(&self, method: &RestMethod) -> &BindingEntry { + for entry in &self.entries { + if resource_tree_contains_method(&entry.doc.resources, method) { + return entry; + } + } + &self.entries[0] + } + + /// Execute an API method by name, using the same executor as built-in + /// commands. Automatically routes to the binding that owns `method`. + pub fn execute( + &self, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + output_format: &formatter::OutputFormat, + ) -> Result<(), CliError> { + let entry = self.entry_for_method(method); + let pagination = executor::PaginationConfig::default(); + let pipeline = formatter::OutputPipeline { + format: output_format.clone(), + color_mode: formatter::ColorMode::default(), + quiet: self.quiet, + }; + + tokio::runtime::Handle::current() + .block_on(executor::execute_method( + &entry.doc, + method, + params_json, + body_json, + &entry.auth_provider, + false, + &pagination, + &pipeline, + false, + None, + &entry.http_config, + )) + .map(|_| ()) + } + + /// Returns a reference to the loaded API spec. + /// + /// When multiple `GraphqlBinding`s are registered, this returns the + /// first binding's spec. Use [`find_method`](Self::find_method) to + /// search across all bindings. + pub fn spec(&self) -> &RestDescription { + &self.entries[0].doc + } + + /// Returns references to all loaded API specs. + pub fn specs(&self) -> Vec<&RestDescription> { + self.entries.iter().map(|e| &e.doc).collect() + } + + /// Search all registered specs for a method at `resource.method_name`. + pub fn find_method( + &self, + resource: &str, + method_name: &str, + ) -> Result<&RestMethod, CliError> { + for entry in &self.entries { + if let Some(r) = entry.doc.resources.get(resource) { + if let Some(m) = r.methods.get(method_name) { + return Ok(m); + } + } + } + Err(CliError::Validation(format!( + "no method '{method_name}' found in resource '{resource}' across {} binding(s)", + self.entries.len(), + ))) + } + + /// Returns a reference to the HTTP/TLS configuration for this CLI run. + /// + /// See [`crate::openapi::AppContext::http_config`] for the design + /// rationale and how non-reqwest transports consume this. + pub fn http_config(&self) -> &crate::http::HttpConfig { + &self.entries[0].http_config + } +} + +/// Recursively check whether any method in the resource tree is +/// pointer-equal to `target`. +fn resource_tree_contains_method( + resources: &std::collections::HashMap, + target: &RestMethod, +) -> bool { + for resource in resources.values() { + for m in resource.methods.values() { + if std::ptr::eq(m, target) { + return true; + } + } + if resource_tree_contains_method(&resource.resources, target) { + return true; + } + } + false +} + +/// Recursively walks clap ArgMatches to find the leaf method and its matches. +pub fn resolve_method_from_matches<'a>( + doc: &'a RestDescription, + matches: &'a clap::ArgMatches, +) -> Result<(&'a RestMethod, &'a clap::ArgMatches), CliError> { + let mut path: Vec<&str> = Vec::new(); + let mut current_matches = matches; + + while let Some((sub_name, sub_matches)) = current_matches.subcommand() { + path.push(sub_name); + current_matches = sub_matches; + } + + if path.is_empty() { + return Err(CliError::Validation( + "No resource or method specified".to_string(), + )); + } + + let resource_name = path[0]; + let resource = doc + .resources + .get(resource_name) + .ok_or_else(|| CliError::Validation(format!("Resource '{resource_name}' not found")))?; + + let mut current_resource = resource; + + for &name in &path[1..path.len() - 1] { + if let Some(sub) = current_resource.resources.get(name) { + current_resource = sub; + } else { + return Err(CliError::Validation(format!( + "Sub-resource '{name}' not found" + ))); + } + } + + let method_name = path[path.len() - 1]; + + if let Some(method) = current_resource.methods.get(method_name) { + return Ok((method, current_matches)); + } + + Err(CliError::Validation(format!( + "Method '{method_name}' not found on resource. Available methods: {:?}", + current_resource.methods.keys().collect::>() + ))) +} + +/// Collect individual flag values into a params map. +/// Values from --params JSON override individual flags. +pub(crate) fn collect_params_from_flags( + matched_args: &clap::ArgMatches, + method: &crate::graphql::discovery::GraphQLOperation, + params_override: Option<&str>, +) -> Result, CliError> { + let mut params = serde_json::Map::new(); + + // Collect values from individual flags + for param_name in method.parameters.keys() { + if let Some(value) = matched_args.get_one::(param_name) { + params.insert(param_name.clone(), serde_json::Value::String(value.clone())); + } + } + + // Override with --params JSON if provided (--params wins) + if let Some(json_str) = params_override { + let overrides: serde_json::Map = + serde_json::from_str(json_str) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))?; + for (key, value) in overrides { + params.insert(key, value); + } + } + + Ok(params) +} + +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { + executor::PaginationConfig { + page_all: matches.get_flag("page-all"), + page_limit: matches + .get_one::("page-limit") + .copied() + .unwrap_or(10), + page_delay_ms: matches + .get_one::("page-delay") + .copied() + .unwrap_or(100), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_graphql_cli_app_builder() { + let app = CliApp::new("test").spec("{}"); + assert_eq!(app.name, "test"); + assert!(app.spec_json.is_some()); + } + + #[test] + fn test_graphql_auth_scheme_records_binding() { + let app = CliApp::new("t") + .spec("{}") + .auth_scheme("bearerAuth", AuthCredentialSource::from_env("T")); + assert_eq!(app.auth_bindings.len(), 1); + } + + #[test] + fn test_graphql_cli_app_endpoint() { + let app = CliApp::new("graphql-fixture") + .spec("{}") + .endpoint("https://example.com/graphql"); + assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); + } + +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/graphql/binding.rs b/seed/cli/query-parameters-openapi/github-npm/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`GraphqlBinding`] — adapts [`super::CliApp`] to the root +//! [`crate::binding::Binding`] trait so it can be composed into +//! a root-level [`crate::app::CliApp`]. + +use std::sync::Arc; + +use crate::auth::{AuthCredentialSource, DynAuthProvider}; +use crate::binding::{Binding, BoxFuture, DispatchResult}; +use crate::error::CliError; +use crate::graphql::commands; +use crate::graphql::discovery::GraphQLSchema; +use crate::graphql::executor; + +struct Prepared { + doc: GraphQLSchema, + http_config: crate::http::HttpConfig, + auth_provider: DynAuthProvider, +} + +/// A GraphQL binding that wraps [`super::CliApp`]'s internals and +/// exposes them through the [`Binding`] trait. +#[must_use] +pub struct GraphqlBinding { + inner: super::CliApp, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(None), + } + } +} + +impl GraphqlBinding { + /// Create a new GraphQL binding. The CLI name is set automatically + /// by `CliApp::binding()` — no need to pass it here. + pub fn new() -> Self { + Self::default() + } + + pub fn spec(mut self, json: &str) -> Self { + self.inner = self.inner.spec(json); + self + } + + pub fn endpoint(mut self, url: &str) -> Self { + self.inner = self.inner.endpoint(url); + self + } + + pub fn auth_scheme_env(mut self, scheme_name: &str, env_var: &str) -> Self { + self.inner = self.inner.auth_scheme_env(scheme_name, env_var); + self + } + + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.inner = self.inner.auth_scheme(scheme_name, source); + self + } + + pub fn auth_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + self + } + + pub fn auth_provider( + mut self, + scheme_name: &str, + provider: impl crate::auth::provider::AuthProvider + 'static, + ) -> Self { + self.inner = self.inner.auth_provider(scheme_name, provider); + self + } + + fn ensure_prepared(&self) -> Result, CliError> { + let mut guard = self.prepared.lock().unwrap(); + if let Some(ref arc) = *guard { + return Ok(Arc::clone(arc)); + } + + let json = self.inner.spec_json.as_deref().ok_or_else(|| { + CliError::Discovery("No spec provided. Call .spec() on GraphqlBinding.".to_string()) + })?; + let endpoint = self.inner.endpoint_url.as_deref().ok_or_else(|| { + CliError::Discovery( + "No endpoint provided. Call .endpoint() on GraphqlBinding.".to_string(), + ) + })?; + let doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + let http_config = crate::http::HttpConfig::new(&self.inner.name)? + .with_parsed_root_certs( + self.inner.extra_root_certs.iter().cloned(), + self.inner.extra_root_certs_pem.iter().cloned(), + ); + let auth_provider = self.inner.build_auth_provider(); + + let arc = Arc::new(Prepared { + doc, + http_config, + auth_provider, + }); + *guard = Some(Arc::clone(&arc)); + Ok(arc) + } + + /// Build a [`BindingEntry`](super::app::BindingEntry) from this + /// binding's prepared state and the current CLI matches. + fn build_binding_entry( + &self, + matches: &clap::ArgMatches, + ) -> Result { + let prepared = self.ensure_prepared()?; + + // Finalize CLI-arg-bound auth sources against parsed matches, + // mirroring dispatch() so custom command handlers get working auth. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + let auth_provider = if cli_auth_args.is_empty() { + prepared.auth_provider.clone() + } else { + let matches_arc = std::sync::Arc::new(matches.clone()); + let finalized = crate::auth::finalize_bindings( + self.inner.auth_bindings.clone(), + &matches_arc, + ); + self.inner.build_auth_provider_from_finalized(&finalized) + }; + + Ok(super::app::BindingEntry { + doc: prepared.doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + }) + } + + /// Wrap a typed handler function into a [`CliCommandHandler`] that + /// automatically downcasts the binding context to + /// [`AppContext`](super::AppContext). + /// + /// Use this with [`CliApp::command()`](crate::app::CliApp::command) + /// or [`CliApp::command_under()`](crate::app::CliApp::command_under). + pub fn handler( + f: fn(&clap::ArgMatches, &super::AppContext) -> Result<(), crate::error::CliError>, + ) -> crate::app::CliCommandHandler { + Box::new(move |matches: &clap::ArgMatches, ctx: &dyn std::any::Any| { + let ctx = ctx.downcast_ref::().ok_or_else(|| { + crate::error::CliError::Validation( + "handler requires a GraphQL binding context".into(), + ) + })?; + f(matches, ctx) + }) + } +} + +impl Binding for GraphqlBinding { + fn name(&self) -> &str { + &self.inner.name + } + + fn set_cli_name(&mut self, name: &str) { + self.inner.name = name.to_string(); + } + + fn set_root_auth(&mut self, bindings: &[(String, crate::auth::SchemeBinding)]) { + let mut merged = bindings.to_vec(); + merged.extend(std::mem::take(&mut self.inner.auth_bindings)); + self.inner.auth_bindings = merged; + } + + fn build_command(&self) -> Result { + let prepared = self.ensure_prepared()?; + let cli = commands::build_cli(&prepared.doc); + let mut cli = self.inner.decorate_command(cli); + + // Register global -- flags for CLI-bound auth sources + // so clap knows about them before parsing. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + for arg_name in &cli_auth_args { + let kebab = arg_name.replace('_', "-"); + cli = cli.arg( + clap::Arg::new(arg_name.clone()) + .long(kebab) + .global(true) + .value_name(arg_name.to_uppercase()) + .help("Auth credential"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + fn dispatch<'a>( + &'a self, + root_matches: &'a clap::ArgMatches, + _sub_matches: &'a clap::ArgMatches, + _op_path: &'a [String], + ) -> BoxFuture<'a, Result> { + let prepared = match self.ensure_prepared() { + Ok(p) => p, + Err(e) => return Box::pin(async move { Err(e) }), + }; + + Box::pin(async move { + // If any auth source uses CLI flags, finalize them against + // the parsed matches and rebuild the auth provider. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + let auth_provider = if cli_auth_args.is_empty() { + prepared.auth_provider.clone() + } else { + let matches_arc = std::sync::Arc::new(root_matches.clone()); + let finalized = crate::auth::finalize_bindings( + self.inner.auth_bindings.clone(), + &matches_arc, + ); + self.inner.build_auth_provider_from_finalized(&finalized) + }; + + let (method, matched_args) = + super::resolve_method_from_matches(&prepared.doc, root_matches)?; + + let params_override = matched_args + .get_one::("params") + .map(|s| s.as_str()); + let params = super::app::collect_params_from_flags( + matched_args, + method, + params_override, + )?; + let params_json_string = serde_json::to_string(¶ms) + .map_err(|e| CliError::Validation(format!("Failed to serialize params: {e}")))?; + let params_json: Option<&str> = if params.is_empty() { + None + } else { + Some(¶ms_json_string) + }; + + let body_json_owned = crate::cli_args::resolve_body_json(matched_args)?; + let body_json = body_json_owned.as_deref(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + let base_url_override_owned = + crate::cli_args::resolve_base_url_override(root_matches, &self.inner.name)?; + let base_url_override = base_url_override_owned.as_deref(); + + let result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .await?; + + match result { + Some(value) => Ok(DispatchResult::Value(value)), + None => Ok(DispatchResult::Handled), + } + }) + } + + fn binding_context( + &self, + matches: &clap::ArgMatches, + ) -> Result>, CliError> { + let entry = self.build_binding_entry(matches)?; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + + fn merge_binding_context( + &self, + matches: &clap::ArgMatches, + existing: Option>, + ) -> Result>, CliError> { + let entry = self.build_binding_entry(matches)?; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/graphql/commands.rs b/seed/cli/query-parameters-openapi/github-npm/src/graphql/commands.rs new file mode 100644 index 000000000000..a65076c45209 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/graphql/commands.rs @@ -0,0 +1,394 @@ +//! CLI Command Builder +//! +//! Builds a dynamic `clap::Command` tree from the internal API representation. + +use clap::builder::PossibleValuesParser; +use clap::{Arg, Command}; + +use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLResource as RestResource}; +use crate::text::to_kebab_flag; + +/// Names of built-in flags that must not be duplicated by parameter-derived flags. +const BUILTIN_FLAG_NAMES: &[&str] = &[ + "params", + "json", + "format", + "dry-run", + "base-url", + "page-all", + "page-limit", + "page-delay", + "quiet", + "help", +]; + +/// Builds the full CLI command tree from an API description. +pub fn build_cli(doc: &RestDescription) -> Command { + let about_text = doc + .title + .clone() + .unwrap_or_else(|| format!("{} CLI", doc.name)); + let mut root = Command::new(doc.name.clone()) + .about(about_text) + .term_width(200) + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + clap::Arg::new("dry-run") + .long("dry-run") + .help("Validate the request locally without sending it to the API") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + clap::Arg::new("format") + .long("format") + .help("Output format: json (default), table, yaml, csv") + .value_name("FORMAT") + .global(true), + ) + .arg( + clap::Arg::new("base-url") + .long("base-url") + .help("Override the API base URL (e.g. for testing against a mock server)") + .value_name("URL") + .global(true), + ) + .arg( + clap::Arg::new("quiet") + .long("quiet") + .short('q') + .help("Suppress stdout output on success (errors still go to stderr)") + .action(clap::ArgAction::SetTrue) + .global(true), + ); + + // Add resource subcommands + let mut resource_names: Vec<_> = doc.resources.keys().collect(); + resource_names.sort(); + for name in resource_names { + let resource = &doc.resources[name]; + if let Some(cmd) = build_resource_command(name, resource) { + root = root.subcommand(cmd); + } + } + + root +} + +/// Recursively builds a Command for a resource. +/// Returns None if the resource has no methods or sub-resources. +fn build_resource_command(name: &str, resource: &RestResource) -> Option { + let mut cmd = Command::new(name.to_string()) + .about(format!("Operations on '{name}'")) + .subcommand_required(true) + .arg_required_else_help(true); + + let mut has_children = false; + + // Add method subcommands + let mut method_names: Vec<_> = resource.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let method = &resource.methods[method_name]; + + has_children = true; + + let about = crate::text::truncate_description( + method.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + + let mut method_cmd = Command::new(method_name.to_string()) + .about(about) + .arg( + Arg::new("params") + .long("params") + .help("Additional parameters as JSON (overrides individual flags)") + .value_name("JSON"), + ) + .arg( + Arg::new("json") + .long("json") + .help("JSON string for the request body (use `-` to read from stdin)") + .value_name("JSON|-"), + ); + + // Pagination flags + method_cmd = method_cmd + .arg( + Arg::new("page-all") + .long("page-all") + .help("Auto-paginate through all results (NDJSON)") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("page-limit") + .long("page-limit") + .help("Maximum number of pages to fetch (default: 10)") + .value_name("N") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + Arg::new("page-delay") + .long("page-delay") + .help("Delay in milliseconds between page fetches (default: 100)") + .value_name("MS") + .value_parser(clap::value_parser!(u64)), + ); + + // Generate individual flags from method parameters + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for param_name in param_names { + let kebab_name = to_kebab_flag(param_name); + if BUILTIN_FLAG_NAMES.contains(&kebab_name.as_str()) { + continue; + } + + let param = &method.parameters[param_name]; + + let value_name = match param.param_type.as_deref() { + Some("string") => "STRING", + Some("integer") => "NUMBER", + Some("number") => "NUMBER", + Some("boolean") => "BOOLEAN", + _ => "VALUE", + }; + + let help_text = crate::text::truncate_description( + param.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + + let mut arg = Arg::new(param_name.clone()) + .long(kebab_name) + .value_name(value_name) + .help(help_text); + + // Don't promote introspection defaults to clap defaults for flattened + // GraphQL input fields. Per the GraphQL spec, `defaultValue` on an input + // field describes the *server's* fallback when the client omits the field + // — it is not a value the client should always send. Materializing it as a + // clap default makes the flag look user-supplied, which forces the parent + // input object to materialize as a variable even when the user passed + // nothing for it, producing arguments the server may reject. + let is_graphql_input_field = param.graphql_input_arg.is_some(); + if let Some(ref default) = param.default { + if !is_graphql_input_field { + arg = arg.default_value(default.clone()); + } + } + + if let Some(ref enum_values) = param.enum_values { + arg = arg.value_parser(PossibleValuesParser::new(enum_values.clone())); + } + + method_cmd = method_cmd.arg(arg); + } + + cmd = cmd.subcommand(method_cmd); + } + + // Add sub-resource subcommands (recursive) + let mut sub_names: Vec<_> = resource.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let sub_resource = &resource.resources[sub_name]; + if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource) { + has_children = true; + cmd = cmd.subcommand(sub_cmd); + } + } + + if has_children { + Some(cmd) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graphql::discovery::{MethodParameter, GraphQLOperation as RestMethod, GraphQLResource as RestResource}; + use std::collections::HashMap; + + fn make_doc() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert("list".to_string(), RestMethod::default()); + methods.insert("delete".to_string(), RestMethod::default()); + + let mut resources = HashMap::new(); + resources.insert( + "files".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_all_commands_always_shown() { + let doc = make_doc(); + let cmd = build_cli(&doc); + + let files_cmd = cmd + .find_subcommand("files") + .expect("files resource missing"); + + assert!(files_cmd.find_subcommand("list").is_some()); + assert!(files_cmd.find_subcommand("delete").is_some()); + } + + #[test] + fn test_root_uses_doc_name() { + let doc = make_doc(); + let cmd = build_cli(&doc); + assert_eq!(cmd.get_name(), "test-cli"); + } + + #[test] + fn test_method_params_become_flags() { + let mut params = HashMap::new(); + params.insert( + "uuid".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user UUID".to_string()), + required: true, + ..Default::default() + }, + ); + params.insert( + "status".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by status".to_string()), + enum_values: Some(vec!["active".to_string(), "inactive".to_string()]), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "get-user".to_string(), + RestMethod { + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let users_cmd = cmd.find_subcommand("users").expect("users resource missing"); + let get_user_cmd = users_cmd + .find_subcommand("get-user") + .expect("get-user method missing"); + + // Verify individual flags exist + let args: Vec = get_user_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!(args.contains(&"uuid".to_string()), "uuid flag missing"); + assert!(args.contains(&"status".to_string()), "status flag missing"); + assert!(args.contains(&"params".to_string()), "params flag missing"); + } + + #[test] + fn test_builtin_flag_names_not_duplicated() { + let mut params = HashMap::new(); + params.insert( + "format".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Response format".to_string()), + ..Default::default() + }, + ); + params.insert( + "real_param".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("A real param".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "test-method".to_string(), + RestMethod { + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + // This should not panic from duplicate arg names + let cmd = build_cli(&doc); + let things_cmd = cmd + .find_subcommand("things") + .expect("things resource missing"); + let test_cmd = things_cmd + .find_subcommand("test-method") + .expect("test-method missing"); + + let args: Vec = test_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + + // "format" should NOT appear as a duplicated param flag, + // but "real_param" should be present. + assert!( + args.contains(&"real_param".to_string()), + "real_param flag missing" + ); + + // Count occurrences of "format" — should be at most 1 (from the global flag) + let format_count = args.iter().filter(|a| *a == "format").count(); + assert!( + format_count <= 1, + "format flag duplicated: found {format_count}" + ); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/graphql/discovery.rs b/seed/cli/query-parameters-openapi/github-npm/src/graphql/discovery.rs new file mode 100644 index 000000000000..0f7c72a02192 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/graphql/discovery.rs @@ -0,0 +1,145 @@ +//! Internal GraphQL Representation +//! +//! Data structures the parser produces from a GraphQL introspection JSON +//! and the command builder + executor consume. + +use std::collections::HashMap; + +use serde::Deserialize; + +/// Top-level GraphQL schema description. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GraphQLSchema { + pub name: String, + pub version: String, + pub title: Option, + pub description: Option, + /// Endpoint URL the executor POSTs queries to. + pub root_url: String, + #[serde(default)] + pub resources: HashMap, +} + +/// A resource which can contain operations and nested sub-resources. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct GraphQLResource { + #[serde(default)] + pub methods: HashMap, + #[serde(default)] + pub resources: HashMap, +} + +/// A single GraphQL operation (query or mutation). +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GraphQLOperation { + pub id: Option, + pub description: Option, + #[serde(default)] + pub parameters: HashMap, + /// GraphQL operation metadata: query/mutation kind, field name, args, return shape. + pub graphql: Option, + /// Per-method base URL (populated from the spec's server URL during parsing). + /// When non-empty, takes priority over doc.root_url in URL construction. + #[serde(default)] + pub root_url: String, +} + +/// Metadata for a GraphQL operation. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct GraphQLMethodInfo { + /// "query" or "mutation". + pub operation_type: String, + /// The original field name in the schema (e.g., "issueCreate"). + pub field_name: String, + /// Default selection set as a GraphQL fragment string (e.g., "{ id title createdAt }"). + pub default_selection: String, + /// Ordered list of top-level arguments, used to build `$var: Type` declarations. + #[serde(default)] + pub args: Vec, +} + +/// One argument of a GraphQL operation. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct GraphQLArgDef { + /// camelCase argument name as it appears in the schema (e.g., "id", "input"). + pub name: String, + /// kebab-case CLI flag key used to look this argument up in the params map. + pub flag_key: String, + /// Full GraphQL type string including nullability (e.g., "String!", "IssueCreateInput"). + pub gql_type: String, + /// True when this arg takes an input object whose fields were flattened into CLI flags. + pub is_input: bool, + /// True when the argument's GraphQL type is a list (e.g., `[IssueSortInput!]`). + /// Used at variable-build time to wrap the reconstructed input object in a JSON array. + #[serde(default)] + pub is_list: bool, +} + +/// A CLI parameter derived from a GraphQL argument or flattened input field. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MethodParameter { + /// JSON-Schema-flavored type used for value coercion (string/integer/number/boolean). + #[serde(rename = "type")] + pub param_type: Option, + pub description: Option, + #[serde(default)] + pub required: bool, + pub default: Option, + #[serde(rename = "enum")] + pub enum_values: Option>, + /// For flattened input fields: the camelCase name of the top-level argument. + /// E.g., a field flattened from `input: IssueCreateInput` has + /// `graphql_input_arg = Some("input")`. + #[serde(default)] + pub graphql_input_arg: Option, + /// Dotted camelCase path within the input argument for nested input fields. + /// E.g., a field at `input.dateRange.start` has + /// `graphql_field_path = Some("dateRange.start")`. When absent, the path is + /// derived from the flag key (top-level flattened field). + #[serde(default)] + pub graphql_field_path: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_graphql_schema() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/graphql", + "resources": { + "issue": { + "methods": { + "get": {} + } + } + } + }"#; + + let doc: GraphQLSchema = serde_json::from_str(json).unwrap(); + assert_eq!(doc.name, "test"); + assert_eq!(doc.root_url, "https://api.example.com/graphql"); + + let issue = doc.resources.get("issue").expect("issue resource missing"); + assert!(issue.methods.contains_key("get")); + } + + #[test] + fn test_deserialize_defaults() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/graphql" + }"#; + + let doc: GraphQLSchema = serde_json::from_str(json).unwrap(); + assert!(doc.resources.is_empty()); + } + +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/graphql/executor.rs b/seed/cli/query-parameters-openapi/github-npm/src/graphql/executor.rs new file mode 100644 index 000000000000..dab3e92329ae --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/graphql/executor.rs @@ -0,0 +1,909 @@ +//! GraphQL Request Execution +//! +//! Builds and dispatches POST requests carrying GraphQL operations. +//! Handles auth, response unwrapping (`data` envelope and `errors`), +//! and cursor-based pagination via `pageInfo.endCursor`. + +use std::collections::HashMap; + +use anyhow::Context; +use serde_json::{json, Map, Value}; + +use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; +use crate::graphql::discovery::{ + GraphQLArgDef, GraphQLMethodInfo, GraphQLOperation, GraphQLSchema, MethodParameter, +}; + +/// Configuration for cursor-based auto-pagination. +#[derive(Debug, Clone)] +pub struct PaginationConfig { + /// Whether to auto-paginate through all pages. + pub page_all: bool, + /// Maximum number of pages to fetch (default: 10). + pub page_limit: u32, + /// Delay between page fetches in milliseconds (default: 100). + pub page_delay_ms: u64, +} + +impl Default for PaginationConfig { + fn default() -> Self { + Self { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + } + } +} + +/// Parsed inputs ready for request execution. +#[derive(Debug)] +struct ExecutionInput { + params: Map, + body: Value, + full_url: String, +} + +fn parse_and_validate_inputs( + doc: &GraphQLSchema, + method: &GraphQLOperation, + params_json: Option<&str>, + body_json: Option<&str>, + base_url_override: Option<&str>, +) -> Result { + let params: Map = if let Some(p) = params_json { + serde_json::from_str(p) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))? + } else { + Map::new() + }; + + let gql = method.graphql.as_ref().ok_or_else(|| { + CliError::Discovery("GraphQL method info missing from spec".to_string()) + })?; + + for (param_name, param_def) in &method.parameters { + if param_def.required + && !params.contains_key(param_name) + && param_def.graphql_input_arg.is_none() + { + return Err(CliError::Validation(format!( + "Required parameter '{param_name}' is missing" + ))); + } + } + + let body = build_graphql_body(gql, ¶ms, body_json, &method.parameters, None)?; + let full_url = base_url_override + .map(|u| u.trim_end_matches('/').to_string()) + .unwrap_or_else(|| doc.root_url.clone()); + + Ok(ExecutionInput { params, body, full_url }) +} + +/// Build a POST request with auth and a JSON GraphQL body. +fn build_http_request( + client: &reqwest::Client, + input: &ExecutionInput, + auth_provider: &DynAuthProvider, +) -> Result { + let request = client.post(&input.full_url); + // GraphQL has no per-operation security metadata in the introspection + // schema, so the metadata is always "unspecified" — the provider's own + // default policy decides what to attach. + let request = auth_provider.apply(request, &EndpointAuthMetadata::unspecified())?; + let request = request + .header("Content-Type", "application/json") + .json(&input.body); + Ok(request) +} + +/// Parse a GraphQL response body: surface `errors` and unwrap the `data` envelope. +/// +/// GraphQL allows partial results: a response may have both `data` and `errors` +/// (common in federation). When both are present, errors are printed to stderr +/// and the partial data is returned. Only when there is no `data` at all do we +/// treat the errors as fatal. +fn parse_graphql_response(body_text: &str) -> Result { + let json_val: Value = serde_json::from_str(body_text).map_err(|e| CliError::Api { + code: 400, + message: format!("Invalid GraphQL response: {e}"), + reason: "graphql_parse_error".to_string(), + })?; + + let has_data = json_val + .get("data") + .map(|d| !d.is_null()) + .unwrap_or(false); + + if let Some(errors) = json_val.get("errors").and_then(|e| e.as_array()) { + if !errors.is_empty() { + let message = errors + .iter() + .filter_map(|e| e.get("message").and_then(|m| m.as_str())) + .collect::>() + .join("; "); + if has_data { + eprintln!("GraphQL partial errors: {message}"); + } else { + return Err(CliError::Api { + code: 400, + message, + reason: "graphql_error".to_string(), + }); + } + } + } + + let unwrapped = if let Some(data) = json_val.get("data").filter(|d| !d.is_null()) { + if let Value::Object(map) = data { + if map.len() == 1 { + map.values().next().unwrap().clone() + } else { + data.clone() + } + } else { + data.clone() + } + } else { + json_val + }; + + serde_json::to_string(&unwrapped).map_err(|e| CliError::Api { + code: 500, + message: format!("Failed to serialize GraphQL response: {e}"), + reason: "graphql_serialize_error".to_string(), + }) +} + +/// Print or capture a JSON response and bump the page counter. +async fn handle_json_response( + body_text: &str, + pipeline: &crate::formatter::OutputPipeline, + pages_fetched: &mut u32, + page_all: bool, + capture_output: bool, + captured: &mut Vec, +) -> Result<(), CliError> { + if let Ok(json_val) = serde_json::from_str::(body_text) { + *pages_fetched += 1; + + if capture_output { + captured.push(json_val); + } else if page_all { + let is_first_page = *pages_fetched == 1; + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &json_val, true, is_first_page) + .context("Failed to write output")?; + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &json_val, false, true) + .context("Failed to write output")?; + } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); + } + Ok(()) +} + + +/// Executes a GraphQL operation. +/// +/// Posts the rendered query to the schema's endpoint, unwraps the `data` envelope, +/// and continues paginating via `pageInfo.endCursor` until the page limit is hit. +#[allow(clippy::too_many_arguments)] +pub async fn execute_method( + doc: &GraphQLSchema, + method: &GraphQLOperation, + params_json: Option<&str>, + body_json: Option<&str>, + auth_provider: &DynAuthProvider, + dry_run: bool, + pagination: &PaginationConfig, + pipeline: &crate::formatter::OutputPipeline, + capture_output: bool, + base_url_override: Option<&str>, + http_config: &crate::http::HttpConfig, +) -> Result, CliError> { + let mut input = + parse_and_validate_inputs(doc, method, params_json, body_json, base_url_override)?; + + if dry_run { + let dry_run_info = json!({ + "dry_run": true, + "url": input.full_url, + "method": "POST", + "body": input.body, + }); + if capture_output { + return Ok(Some(dry_run_info)); + } + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &dry_run_info, false, true) + .context("Failed to write output")?; + return Ok(None); + } + + let mut pages_fetched: u32 = 0; + let mut captured_values = Vec::new(); + + // Build the client once outside the pagination loop. Client construction + // reads env vars and (with TLS) builds a connection pool; rebuilding per + // page would defeat connection reuse and emit any one-time warnings + // (e.g. insecure-mode) once per page. + let client = http_config.build_client()?; + + loop { + let request = build_http_request(&client, &input, auth_provider)?; + + let method_id = method.id.as_deref().unwrap_or("unknown"); + let start = std::time::Instant::now(); + let response = match request.send().await { + Ok(resp) => resp, + Err(e) => { + // Surface a human-readable hint to stderr if this looks like + // a TLS failure — the most common debugging hump for users + // behind corporate proxies / interception tools. The hint is + // a side effect; the error then propagates up like any other. + crate::http::maybe_emit_tls_hint(http_config, &e); + return Err(anyhow::Error::from(e).context("HTTP request failed").into()); + } + }; + let latency_ms = start.elapsed().as_millis() as u64; + + let status = response.status(); + + if !status.is_success() { + let error_body = response.text().await.unwrap_or_default(); + tracing::warn!( + api_method = method_id, + http_method = "POST", + status = status.as_u16(), + latency_ms = latency_ms, + "API error" + ); + return handle_error_response( + status, + &error_body, + auth_provider.as_ref(), + &EndpointAuthMetadata::unspecified(), + ); + } + + tracing::debug!( + api_method = method_id, + http_method = "POST", + status = status.as_u16(), + latency_ms = latency_ms, + page = pages_fetched, + "API request" + ); + + let body_text = response + .text() + .await + .context("Failed to read response body")?; + let response_body = parse_graphql_response(&body_text)?; + + handle_json_response( + &response_body, + pipeline, + &mut pages_fetched, + pagination.page_all, + capture_output, + &mut captured_values, + ) + .await?; + + // GraphQL cursor-based pagination: rebuild the body with the next + // cursor and POST again until we run out of pages or hit the limit. + if pagination.page_all { + if let Some(cursor) = extract_graphql_cursor(&response_body) { + if pages_fetched < pagination.page_limit { + if let Some(ref gql_info) = method.graphql { + let params_clone = input.params.clone(); + input.body = build_graphql_body( + gql_info, + ¶ms_clone, + body_json, + &method.parameters, + Some(&cursor), + )?; + } + if pagination.page_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis( + pagination.page_delay_ms, + )) + .await; + } + continue; + } + } + } + + break; + } + + if capture_output && !captured_values.is_empty() { + if captured_values.len() == 1 { + return Ok(Some(captured_values.pop().unwrap())); + } else { + return Ok(Some(Value::Array(captured_values))); + } + } + + Ok(None) +} + +/// Build a GraphQL request body using the variables mechanism. +/// +/// User-supplied values are placed in the `variables` JSON object and referenced +/// via `$name: Type` declarations in the query — they never appear in the query +/// string itself, preventing GraphQL injection. +/// +/// `cursor` injects an `after` variable for cursor-based pagination when +/// `page-all` is in effect; it is only applied when the method declares an +/// `after` argument. +fn build_graphql_body( + gql: &GraphQLMethodInfo, + params: &Map, + body_json: Option<&str>, + method_params: &HashMap, + cursor: Option<&str>, +) -> Result { + let mut variables: Map = Map::new(); + + // Parse --json once; it targets the first input arg only. + let body_obj: Option> = if let Some(json_str) = body_json { + let json_val: Value = serde_json::from_str(json_str) + .map_err(|e| CliError::Validation(format!("Invalid --json body: {e}")))?; + match json_val { + Value::Object(obj) => Some(obj), + _ => None, + } + } else { + None + }; + let mut json_applied = false; + + for arg_def in &gql.args { + if arg_def.is_input { + // Reconstruct the input object from flattened CLI flags. Each flag + // tagged with this arg_name carries a graphql_field_path (dotted + // camelCase path within the input) for nested field placement. + let mut input_obj: Map = Map::new(); + for (flag_key, value) in params { + if let Some(mp) = method_params.get(flag_key) { + if mp.graphql_input_arg.as_deref() == Some(arg_def.name.as_str()) { + let coerced = coerce_graphql_value(value, Some(mp)); + if let Some(path) = mp.graphql_field_path.as_deref() { + set_nested_value(&mut input_obj, path, coerced); + } else { + let camel = kebab_to_camel(flag_key); + set_nested_value(&mut input_obj, &camel, coerced); + } + } + } + } + // --json targets the first input arg only (deep-merges at the top level). + if !json_applied { + if let Some(ref obj) = body_obj { + for (k, v) in obj { + input_obj.insert(k.clone(), v.clone()); + } + json_applied = true; + } + } + if !input_obj.is_empty() { + // For list-typed input arguments (e.g. `arg: [SomeInput!]`), the + // variable must be serialized as a JSON array. The GraphQL spec + // defines input coercion that lifts a single value into a singleton + // list, but coercion of typed *variables* is not uniformly enforced + // across server implementations — emitting an explicit array is the + // portable, spec-conformant shape. We currently flatten one element's + // worth of fields, so wrap the reconstructed object accordingly. + let value = if arg_def.is_list { + Value::Array(vec![Value::Object(input_obj)]) + } else { + Value::Object(input_obj) + }; + variables.insert(arg_def.name.clone(), value); + } + } else { + // Direct scalar/enum arg: look it up by its CLI flag key. + if let Some(value) = params.get(&arg_def.flag_key) { + let coerced = coerce_graphql_value(value, method_params.get(&arg_def.flag_key)); + variables.insert(arg_def.name.clone(), coerced); + } + } + } + + // Inject pagination cursor when the method declares an `after` argument. + if let Some(cursor_val) = cursor { + if gql.args.iter().any(|a| a.name == "after") { + variables.insert("after".to_string(), Value::String(cursor_val.to_string())); + } + } + + let op_type = &gql.operation_type; + let field_name = &gql.field_name; + let selection = &gql.default_selection; + + let query = if variables.is_empty() { + format!("{op_type} {{ {field_name} {selection} }}") + } else { + let present_args: Vec<&GraphQLArgDef> = gql + .args + .iter() + .filter(|a| variables.contains_key(&a.name)) + .collect(); + let decls = present_args + .iter() + .map(|a| format!("${}: {}", a.name, a.gql_type)) + .collect::>() + .join(", "); + let refs = present_args + .iter() + .map(|a| format!("{}: ${}", a.name, a.name)) + .collect::>() + .join(", "); + format!("{op_type}({decls}) {{ {field_name}({refs}) {selection} }}") + }; + + Ok(json!({ + "query": query, + "variables": variables, + })) +} + +/// Set a value at a dotted camelCase path within a JSON object, creating +/// intermediate objects as needed. E.g., path `"dateRange.start"` sets +/// `obj["dateRange"]["start"] = value`. +fn set_nested_value(obj: &mut Map, path: &str, value: Value) { + match path.split_once('.') { + None => { + obj.insert(path.to_string(), value); + } + Some((head, tail)) => { + let nested = obj + .entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(nested_map) = nested { + set_nested_value(nested_map, tail, value); + } + } + } +} + +/// Extract `endCursor` from an unwrapped GraphQL response when `hasNextPage` is true. +fn extract_graphql_cursor(response_body: &str) -> Option { + let val: Value = serde_json::from_str(response_body).ok()?; + let page_info = val.get("pageInfo")?; + let has_next = page_info.get("hasNextPage")?.as_bool()?; + if !has_next { + return None; + } + page_info + .get("endCursor")? + .as_str() + .map(|s| s.to_string()) +} + +/// Coerce a JSON value to the correct type based on the parameter definition. +/// CLI flags always come in as strings; this converts "3" → 3 for integers, etc. +fn coerce_graphql_value(value: &Value, param_def: Option<&MethodParameter>) -> Value { + if let Value::String(s) = value { + if let Some(def) = param_def { + match def.param_type.as_deref() { + Some("integer") => { + if let Ok(n) = s.parse::() { + return Value::Number(n.into()); + } + } + Some("number") => { + if let Ok(n) = s.parse::() { + if let Some(num) = serde_json::Number::from_f64(n) { + return Value::Number(num); + } + } + } + Some("boolean") => match s.as_str() { + "true" => return Value::Bool(true), + "false" => return Value::Bool(false), + _ => {} + }, + _ => {} + } + } + } + value.clone() +} + +/// Convert kebab-case to camelCase. +fn kebab_to_camel(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut capitalize_next = false; + for ch in s.chars() { + if ch == '-' { + capitalize_next = true; + } else if capitalize_next { + result.push(ch.to_uppercase().next().unwrap()); + capitalize_next = false; + } else { + result.push(ch); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_pagination_config_default() { + let config = PaginationConfig::default(); + assert!(!config.page_all); + assert_eq!(config.page_limit, 10); + assert_eq!(config.page_delay_ms, 100); + } + + // ----------------------------------------------------------------------- + // handle_json_response + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_handle_json_response_capture_output() { + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut captured = Vec::new(); + + handle_json_response( + r#"{"items":["a"]}"#, + &pipeline, + &mut pages_fetched, + false, + true, + &mut captured, + ) + .await + .unwrap(); + + assert_eq!(captured.len(), 1); + assert_eq!(pages_fetched, 1); + } + + #[tokio::test] + async fn test_handle_json_response_non_json_body() { + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut captured = Vec::new(); + + handle_json_response( + "not json at all", + &pipeline, + &mut pages_fetched, + false, + false, + &mut captured, + ) + .await + .unwrap(); + + assert_eq!(pages_fetched, 0); + } + + // ----------------------------------------------------------------------- + // build_http_request + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_build_http_request_posts_json_body() { + let client = reqwest::Client::new(); + let input = ExecutionInput { + full_url: "https://example.com/graphql".to_string(), + body: json!({"query": "{ ping }", "variables": {}}), + params: Map::new(), + }; + + let request = build_http_request(&client, &input, &crate::auth::no_auth_provider()).unwrap(); + let built = request.build().unwrap(); + + assert_eq!(built.method(), "POST"); + assert_eq!( + built + .headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + ); + } + + // ----------------------------------------------------------------------- + // execute_method + // ----------------------------------------------------------------------- + + fn minimal_ping_doc_and_method() -> (GraphQLSchema, GraphQLOperation) { + let doc = GraphQLSchema { + name: "test".to_string(), + version: "v1".to_string(), + root_url: "https://example.com/graphql".to_string(), + ..Default::default() + }; + let method = GraphQLOperation { + id: Some("ping".to_string()), + graphql: Some(crate::graphql::discovery::GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "ping".to_string(), + default_selection: String::new(), + args: Vec::new(), + }), + ..Default::default() + }; + (doc, method) + } + + #[tokio::test] + async fn test_execute_method_dry_run_with_http_config() { + // dry_run skips network I/O entirely, but still exercises the new + // http_config parameter path — proving that the caller's + // HttpConfig is plumbed all the way to execute_method. + let (doc, method) = minimal_ping_doc_and_method(); + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let http_config = crate::http::HttpConfig::new("test").unwrap(); + + let result = execute_method( + &doc, + &method, + None, + None, + &crate::auth::no_auth_provider(), + true, // dry_run + &pagination, + &pipeline, + true, // capture_output + None, + &http_config, + ) + .await + .expect("dry-run should succeed"); + + let value = result.expect("dry-run with capture_output should return Some"); + assert_eq!(value["dry_run"], json!(true)); + assert_eq!(value["url"], json!("https://example.com/graphql")); + assert_eq!(value["method"], json!("POST")); + } + + // ----------------------------------------------------------------------- + // parse_graphql_response + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_graphql_response_errors_only_is_fatal() { + let body = json!({ + "errors": [{"message": "Not found"}] + }) + .to_string(); + let result = parse_graphql_response(&body); + assert!(result.is_err(), "errors-only should be fatal"); + } + + #[test] + fn test_parse_graphql_response_errors_and_data_returns_data() { + let body = json!({ + "data": {"node": {"id": "n1", "name": "test"}}, + "errors": [{"message": "partial failure"}] + }) + .to_string(); + let result = parse_graphql_response(&body).expect("errors+data should succeed"); + let val: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(val["id"], "n1", "partial data should be returned"); + } + + #[test] + fn test_parse_graphql_response_null_data_with_errors_is_fatal() { + let body = json!({ + "data": null, + "errors": [{"message": "fatal"}] + }) + .to_string(); + let result = parse_graphql_response(&body); + assert!(result.is_err(), "null data + errors should be fatal"); + } + + #[test] + fn test_parse_graphql_response_unwraps_single_field() { + let body = json!({ + "data": {"issues": {"nodes": [{"id": "i1"}]}} + }) + .to_string(); + let result = parse_graphql_response(&body).unwrap(); + let val: Value = serde_json::from_str(&result).unwrap(); + assert!(val.get("nodes").is_some(), "should unwrap single-field data envelope"); + } + + // ----------------------------------------------------------------------- + // extract_graphql_cursor + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_graphql_cursor_returns_cursor_when_has_next() { + let body = json!({ + "nodes": [], + "pageInfo": {"hasNextPage": true, "endCursor": "cursor-abc"} + }) + .to_string(); + let cursor = extract_graphql_cursor(&body); + assert_eq!(cursor, Some("cursor-abc".to_string())); + } + + #[test] + fn test_extract_graphql_cursor_returns_none_when_no_next() { + let body = json!({ + "nodes": [], + "pageInfo": {"hasNextPage": false, "endCursor": "cursor-abc"} + }) + .to_string(); + assert_eq!(extract_graphql_cursor(&body), None); + } + + #[test] + fn test_extract_graphql_cursor_returns_none_when_no_page_info() { + let body = json!({"nodes": []}).to_string(); + assert_eq!(extract_graphql_cursor(&body), None); + } + + // ----------------------------------------------------------------------- + // set_nested_value + // ----------------------------------------------------------------------- + + #[test] + fn test_set_nested_value_flat() { + let mut obj = Map::new(); + set_nested_value(&mut obj, "name", Value::String("alice".to_string())); + assert_eq!(obj["name"], "alice"); + } + + #[test] + fn test_set_nested_value_two_levels() { + let mut obj = Map::new(); + set_nested_value( + &mut obj, + "dateRange.start", + Value::String("2024-01-01".to_string()), + ); + set_nested_value( + &mut obj, + "dateRange.end", + Value::String("2024-12-31".to_string()), + ); + let date_range = obj["dateRange"].as_object().unwrap(); + assert_eq!(date_range["start"], "2024-01-01"); + assert_eq!(date_range["end"], "2024-12-31"); + } + + #[test] + fn test_set_nested_value_three_levels() { + let mut obj = Map::new(); + set_nested_value(&mut obj, "a.b.c", Value::String("deep".to_string())); + assert_eq!(obj["a"]["b"]["c"], "deep"); + } + + // ----------------------------------------------------------------------- + // build_graphql_body + // ----------------------------------------------------------------------- + + #[test] + fn test_build_graphql_body_injects_cursor_when_after_arg_present() { + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "nodes".to_string(), + default_selection: "{ nodes { id } pageInfo { hasNextPage endCursor } }".to_string(), + args: vec![ + GraphQLArgDef { + name: "first".to_string(), + flag_key: "first".to_string(), + gql_type: "Int".to_string(), + is_input: false, + is_list: false, + }, + GraphQLArgDef { + name: "after".to_string(), + flag_key: "after".to_string(), + gql_type: "String".to_string(), + is_input: false, + is_list: false, + }, + ], + }; + let params = Map::new(); + let method_params: HashMap = HashMap::new(); + + let body = + build_graphql_body(&gql, ¶ms, None, &method_params, Some("cursor-xyz")).unwrap(); + let vars = body["variables"].as_object().unwrap(); + assert_eq!(vars.get("after").and_then(|v| v.as_str()), Some("cursor-xyz")); + assert!(body["query"].as_str().unwrap().contains("$after: String")); + } + + #[test] + fn test_build_graphql_body_no_cursor_when_no_after_arg() { + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "node".to_string(), + default_selection: "{ id name }".to_string(), + args: vec![GraphQLArgDef { + name: "id".to_string(), + flag_key: "id".to_string(), + gql_type: "String!".to_string(), + is_input: false, + is_list: false, + }], + }; + let mut params = Map::new(); + params.insert("id".to_string(), Value::String("n1".to_string())); + let method_params: HashMap = HashMap::new(); + + let body = + build_graphql_body(&gql, ¶ms, None, &method_params, Some("cursor-xyz")).unwrap(); + let vars = body["variables"].as_object().unwrap(); + assert!(vars.get("after").is_none(), "no after arg = cursor not injected"); + } + + #[test] + fn test_build_graphql_body_reconstructs_nested_input() { + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "filteredNodes".to_string(), + default_selection: "{ nodes { id } }".to_string(), + args: vec![GraphQLArgDef { + name: "filter".to_string(), + flag_key: "filter".to_string(), + gql_type: "NodeFilter".to_string(), + is_input: true, + is_list: false, + }], + }; + + let mut params = Map::new(); + params.insert( + "date-range-start".to_string(), + Value::String("2024-01-01".to_string()), + ); + params.insert( + "date-range-end".to_string(), + Value::String("2024-12-31".to_string()), + ); + + let mut method_params: HashMap = HashMap::new(); + method_params.insert( + "date-range-start".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + graphql_input_arg: Some("filter".to_string()), + graphql_field_path: Some("dateRange.start".to_string()), + ..Default::default() + }, + ); + method_params.insert( + "date-range-end".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + graphql_input_arg: Some("filter".to_string()), + graphql_field_path: Some("dateRange.end".to_string()), + ..Default::default() + }, + ); + + let body = build_graphql_body(&gql, ¶ms, None, &method_params, None).unwrap(); + let filter = body["variables"]["filter"].as_object().unwrap(); + let date_range = filter["dateRange"].as_object().unwrap(); + assert_eq!(date_range["start"], "2024-01-01"); + assert_eq!(date_range["end"], "2024-12-31"); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/graphql/help.rs b/seed/cli/query-parameters-openapi/github-npm/src/graphql/help.rs new file mode 100644 index 000000000000..0974c5ea7e46 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/graphql/help.rs @@ -0,0 +1,373 @@ +//! JSON help output — renders `--help --format json` as a machine-readable +//! schema. When an agent passes both `--help` (or `-h`) and `--format json`, +//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. + +use serde_json::{json, Map, Value}; + +use crate::error::CliError; +use crate::graphql::discovery::{GraphQLOperation, GraphQLResource, GraphQLSchema}; + +/// Renders JSON help for the given subcommand path and prints it to stdout. +#[cfg(test)] +pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { + let output = match path.len() { + 0 => list_all_operations(doc), + 1 => list_resource_operations(doc, &path[0])?, + _ => { + // Try treating last element as a method name first. + // If that fails, the full path may resolve to a nested sub-resource — list its ops. + let resource_path: Vec<&str> = path[..path.len() - 1].iter().map(|s| s.as_str()).collect(); + let method_name = path[path.len() - 1].as_str(); + match operation_schema(doc, &resource_path, method_name) { + Ok(schema) => schema, + Err(_) => { + let full_path: Vec<&str> = path.iter().map(|s| s.as_str()).collect(); + list_nested_resource_operations(doc, &full_path)? + } + } + } + }; + + writeln!( + out, + "{}", + serde_json::to_string_pretty(&output) + .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? + ) + .map_err(|e| CliError::Other(e.into()))?; + Ok(()) +} + +fn list_all_operations(doc: &GraphQLSchema) -> Value { + let mut ops: Vec = Vec::new(); + let mut names: Vec<_> = doc.resources.keys().collect(); + names.sort(); + for name in names { + collect_resource_ops(&doc.resources[name], &[name], &mut ops); + } + json!(ops) +} + +fn list_resource_operations(doc: &GraphQLSchema, resource: &str) -> Result { + let res = doc + .resources + .get(resource) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {resource}")))?; + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, &[resource], &mut ops); + Ok(json!(ops)) +} + +fn list_nested_resource_operations(doc: &GraphQLSchema, path: &[&str]) -> Result { + let first = path.first().ok_or_else(|| { + CliError::Validation("No resource specified".to_string()) + })?; + let mut res = doc + .resources + .get(*first) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {first}")))?; + for segment in &path[1..] { + res = res + .resources + .get(*segment) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {segment}")))?; + } + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, path, &mut ops); + Ok(json!(ops)) +} + +fn operation_schema(doc: &GraphQLSchema, resource_path: &[&str], method_name: &str) -> Result { + let first = resource_path.first().ok_or_else(|| { + CliError::Validation("No resource specified".to_string()) + })?; + + let mut res = doc + .resources + .get(*first) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {first}")))?; + + for segment in &resource_path[1..] { + res = res + .resources + .get(*segment) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {segment}")))?; + } + + let method = res.methods.get(method_name).ok_or_else(|| { + CliError::Validation(format!( + "Operation not found: {} {method_name}", + resource_path.join(" ") + )) + })?; + + Ok(build_schema(resource_path, method_name, method)) +} + +fn build_schema(resource_path: &[&str], method_name: &str, method: &GraphQLOperation) -> Value { + let mut properties: Map = Map::new(); + let mut required: Vec = Vec::new(); + + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for name in param_names { + let param = &method.parameters[name]; + let mut prop = json!({ + "type": param.param_type.as_deref().unwrap_or("string"), + "description": param.description.as_deref().unwrap_or(""), + }); + if let Some(enums) = ¶m.enum_values { + prop["enum"] = json!(enums); + } + if param.required { + required.push(name.clone()); + } + properties.insert(name.clone(), prop); + } + required.sort(); + + let (operation_type, field) = method + .graphql + .as_ref() + .map(|g| (g.operation_type.as_str(), g.field_name.as_str())) + .unwrap_or(("query", "")); + + json!({ + "operation": format!("{}.{}", resource_path.join("."), method_name), + "operationType": operation_type, + "field": field, + "description": method.description.as_deref().unwrap_or(""), + "parameters": { + "type": "object", + "properties": properties, + "required": required, + }, + }) +} + +fn collect_resource_ops(res: &GraphQLResource, path: &[&str], ops: &mut Vec) { + let mut method_names: Vec<_> = res.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let m = &res.methods[method_name]; + let (operation_type, field) = m + .graphql + .as_ref() + .map(|g| (g.operation_type.as_str(), g.field_name.as_str())) + .unwrap_or(("query", "")); + ops.push(json!({ + "operation": format!("{}.{}", path.join("."), method_name), + "operationType": operation_type, + "field": field, + "description": m.description.as_deref().unwrap_or(""), + })); + } + let mut sub_names: Vec<_> = res.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let mut sub_path = path.to_vec(); + sub_path.push(sub_name); + collect_resource_ops(&res.resources[sub_name], &sub_path, ops); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graphql::discovery::{MethodParameter, GraphQLOperation, GraphQLResource}; + use std::collections::HashMap; + + fn make_doc() -> GraphQLSchema { + use crate::graphql::discovery::GraphQLMethodInfo; + + let mut params = HashMap::new(); + params.insert( + "user_id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user ID".to_string()), + required: true, + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + GraphQLOperation { + description: Some("Get a user".to_string()), + parameters: params, + graphql: Some(GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "user".to_string(), + default_selection: "{ id name }".to_string(), + args: Vec::new(), + }), + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + GraphQLResource { + methods, + resources: HashMap::new(), + }, + ); + GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_render_root_lists_all() { + let doc = make_doc(); + let output = list_all_operations(&doc); + let arr = output.as_array().unwrap(); + assert!(!arr.is_empty()); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_resource() { + let doc = make_doc(); + let output = list_resource_operations(&doc, "users").unwrap(); + let arr = output.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_operation_schema() { + let doc = make_doc(); + let schema = operation_schema(&doc, &["users"], "get").unwrap(); + assert_eq!(schema["operationType"], "query"); + assert_eq!(schema["field"], "user"); + let required = schema["parameters"]["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v == "user_id")); + } + + #[test] + fn test_render_json_help_nested_sub_resource_listing() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::graphql::discovery::GraphQLOperation::default(), + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + GraphQLResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + GraphQLResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into()]; + let result = render_json_help(&doc, &path); + assert!(result.is_ok(), "sub-resource path should list operations, not error"); + } + + #[test] + fn test_render_nested_operation_schema() { + use crate::graphql::discovery::GraphQLMethodInfo; + + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::graphql::discovery::GraphQLOperation { + description: Some("Get a membership".to_string()), + graphql: Some(GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "membership".to_string(), + default_selection: "{ id }".to_string(), + args: Vec::new(), + }), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + GraphQLResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + GraphQLResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let schema = operation_schema(&doc, &["organizations", "memberships"], "get-membership").unwrap(); + assert_eq!(schema["operation"], "organizations.memberships.get-membership"); + assert_eq!(schema["operationType"], "query"); + assert_eq!(schema["field"], "membership"); + } + + #[test] + fn test_render_json_help_dispatches_nested_path() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::graphql::discovery::GraphQLOperation::default(), + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + GraphQLResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + GraphQLResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into(), "get-membership".into()]; + let result = render_json_help(&doc, &path); + assert!(result.is_ok(), "nested path should resolve correctly"); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/graphql/mod.rs b/seed/cli/query-parameters-openapi/github-npm/src/graphql/mod.rs new file mode 100644 index 000000000000..cd021beda24e --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/graphql/mod.rs @@ -0,0 +1,12 @@ +mod app; +mod binding; +pub mod commands; +mod help; +pub mod executor; +mod parser; +pub mod discovery; + +pub use self::app::{AppContext, resolve_method_from_matches}; +pub(crate) use self::app::CliApp; +pub use self::binding::GraphqlBinding; +pub use self::parser::load_graphql_schema; diff --git a/seed/cli/query-parameters-openapi/github-npm/src/graphql/parser.rs b/seed/cli/query-parameters-openapi/github-npm/src/graphql/parser.rs new file mode 100644 index 000000000000..2b956c4bbe08 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/graphql/parser.rs @@ -0,0 +1,974 @@ +//! GraphQL Introspection JSON Parser +//! +//! Converts a GraphQL introspection JSON schema into the internal `GraphQLSchema` +//! used by the CLI command builder and executor. +//! +//! Input format: `{"data": {"__schema": {...}}}` (standard introspection response) +//! or `{"__schema": {...}}` (bare schema). +//! +//! Use `src/bin/strip_schema.rs` to remove descriptions and built-in meta-types +//! before checking in a schema file. + +use serde_json::Value; +use std::collections::HashMap; + +use crate::graphql::discovery::{ + GraphQLArgDef, GraphQLMethodInfo, MethodParameter, GraphQLSchema, GraphQLOperation, GraphQLResource, +}; +use crate::error::CliError; + +/// GraphQL built-in scalar type names. +const BUILTIN_SCALARS: &[&str] = &["String", "Int", "Float", "Boolean", "ID"]; + +/// Known suffixes for mutations, used to split into resource + method name. +const MUTATION_SUFFIXES: &[&str] = &[ + "Unarchive", + "Archive", + "Create", + "Update", + "Delete", + "Remove", + "Connect", + "Disconnect", + "Import", + "Rotate", + "Accept", + "Decline", + "Leave", + "Join", + "Resume", + "Pause", + "Suspend", + "Unsuspend", + "Mark", +]; + +/// Load a GraphQL introspection JSON schema and convert it into a `GraphQLSchema`. +/// +/// Accepts either the full introspection response (`{"data": {"__schema": ...}}`) +/// or the bare schema object (`{"__schema": ...}`). +pub fn load_graphql_schema( + introspection_json: &str, + cli_name: &str, + endpoint: &str, +) -> Result { + let data: Value = serde_json::from_str(introspection_json) + .map_err(|e| CliError::Discovery(format!("Failed to parse introspection JSON: {e}")))?; + + // Support both wrapped and bare introspection responses. + let schema = if data.get("data").is_some() { + &data["data"]["__schema"] + } else { + &data["__schema"] + }; + + let types = schema["types"] + .as_array() + .ok_or_else(|| CliError::Discovery("Missing 'types' array in schema".to_string()))?; + + let mut object_types: HashMap<&str, &Value> = HashMap::new(); + let mut input_types: HashMap<&str, &Value> = HashMap::new(); + let mut enum_types: HashMap<&str, &Value> = HashMap::new(); + let mut scalar_names: Vec = BUILTIN_SCALARS.iter().map(|s| s.to_string()).collect(); + + for ty in types { + let kind = ty["kind"].as_str().unwrap_or(""); + let name = match ty["name"].as_str() { + Some(n) if !n.starts_with("__") => n, + _ => continue, + }; + match kind { + "OBJECT" => { + object_types.insert(name, ty); + } + "INPUT_OBJECT" => { + input_types.insert(name, ty); + } + "ENUM" => { + enum_types.insert(name, ty); + } + "SCALAR" => { + scalar_names.push(name.to_string()); + } + _ => {} + } + } + + let query_type_name = schema["queryType"]["name"].as_str().unwrap_or("Query"); + let mutation_type_name = schema["mutationType"]["name"].as_str(); + + let mut resources: HashMap = HashMap::new(); + let empty_args: Vec = Vec::new(); + + // Process Query fields + if let Some(query_type) = object_types.get(query_type_name) { + let fields = query_type["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + for field in fields { + let field_name = match field["name"].as_str() { + Some(n) if !n.starts_with('_') => n, + _ => continue, + }; + let return_type_name = unwrap_type_name(&field["type"]); + let (resource_name, method_name) = split_query_name(field_name, &return_type_name); + let args = field["args"].as_array().unwrap_or(&empty_args); + let (parameters, arg_defs) = + build_parameters_from_args(args, &input_types, &enum_types, &scalar_names); + let default_selection = + build_default_selection(&return_type_name, &object_types, &scalar_names); + + let method = GraphQLOperation { + id: Some(format!("{resource_name}.{method_name}")), + description: desc(field), + parameters, + graphql: Some(GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: field_name.to_string(), + default_selection, + args: arg_defs, + }), + ..Default::default() + }; + resources.entry(resource_name).or_default().methods.insert(method_name, method); + } + } + + // Process Mutation fields + if let Some(mt_name) = mutation_type_name { + if let Some(mutation_type) = object_types.get(mt_name) { + let fields = mutation_type["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + for field in fields { + let field_name = match field["name"].as_str() { + Some(n) if !n.starts_with('_') => n, + _ => continue, + }; + let return_type_name = unwrap_type_name(&field["type"]); + let (resource_name, method_name) = split_mutation_name(field_name); + let args = field["args"].as_array().unwrap_or(&empty_args); + let (parameters, arg_defs) = + build_parameters_from_args(args, &input_types, &enum_types, &scalar_names); + let default_selection = + build_default_selection(&return_type_name, &object_types, &scalar_names); + + let method = GraphQLOperation { + id: Some(format!("{resource_name}.{method_name}")), + description: desc(field), + parameters, + graphql: Some(GraphQLMethodInfo { + operation_type: "mutation".to_string(), + field_name: field_name.to_string(), + default_selection, + args: arg_defs, + }), + ..Default::default() + }; + resources.entry(resource_name).or_default().methods.insert(method_name, method); + } + } + } + + Ok(GraphQLSchema { + name: cli_name.to_string(), + version: "1".to_string(), + root_url: endpoint.to_string(), + resources, + ..Default::default() + }) +} + +/// Extract an optional description string from a JSON node. +fn desc(val: &Value) -> Option { + val.get("description") + .and_then(|d| d.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn default_val(val: &Value) -> Option { + val.get("defaultValue") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn enum_values(enum_def: &Value) -> Vec { + enum_def["enumValues"] + .as_array() + .map(|ev| { + ev.iter() + .filter_map(|v| v["name"].as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +/// Follow NON_NULL/LIST wrappers to find the named type. +fn unwrap_type_name(ty: &Value) -> String { + match ty["kind"].as_str().unwrap_or("") { + "NON_NULL" | "LIST" => unwrap_type_name(&ty["ofType"]), + _ => ty["name"].as_str().unwrap_or("String").to_string(), + } +} + +/// True when the outermost wrapper is NON_NULL. +fn is_non_null(ty: &Value) -> bool { + ty["kind"].as_str() == Some("NON_NULL") +} + +/// True when the type wraps (at any outer level) a LIST. We descend through +/// NON_NULL wrappers — `[T!]`, `[T!]!`, `[T]` all count as list types. +fn is_list_type(ty: &Value) -> bool { + match ty["kind"].as_str().unwrap_or("") { + "LIST" => true, + "NON_NULL" => is_list_type(&ty["ofType"]), + _ => false, + } +} + +/// Reconstruct the type string including nullability, e.g. `"String!"`, `"[ID!]!"`. +fn gql_type_string(ty: &Value) -> String { + match ty["kind"].as_str().unwrap_or("") { + "NON_NULL" => format!("{}!", gql_type_string(&ty["ofType"])), + "LIST" => format!("[{}]", gql_type_string(&ty["ofType"])), + _ => ty["name"].as_str().unwrap_or("String").to_string(), + } +} + +/// Check if a type name is a known scalar. +fn is_scalar(name: &str, scalar_names: &[String]) -> bool { + scalar_names.iter().any(|s| s == name) +} + +/// Split a query field name into (resource_name, method_name). +fn split_query_name(field_name: &str, return_type: &str) -> (String, String) { + let kebab = camel_to_kebab(field_name); + + // "For" pattern: attachmentsForURL → (attachment, list-for-url) + if let Some(pos) = field_name.find("For") { + if pos > 0 { + let prefix = &field_name[..pos]; + let suffix = &field_name[pos..]; + let resource = camel_to_kebab(&singular_camel(prefix)); + let method = format!("list-{}", camel_to_kebab(suffix).to_lowercase()); + return (resource, method); + } + } + + // Connection return type is authoritative — always a list + if return_type.ends_with("Connection") { + return (singular_kebab(&kebab), "list".to_string()); + } + + // Plural field name heuristic + if field_name.ends_with('s') + && !field_name.ends_with("ss") + && !field_name.ends_with("us") + && !field_name.ends_with("Status") + && field_name.len() > 2 + { + return (singular_kebab(&kebab), "list".to_string()); + } + + (kebab, "get".to_string()) +} + +/// Split a mutation field name into (resource_name, method_name). +fn split_mutation_name(field_name: &str) -> (String, String) { + for suffix in MUTATION_SUFFIXES { + if field_name.ends_with(suffix) && field_name.len() > suffix.len() { + let prefix = &field_name[..field_name.len() - suffix.len()]; + return (camel_to_kebab(prefix), suffix.to_lowercase()); + } + } + if let Some((resource, action)) = split_at_second_word(field_name) { + return (camel_to_kebab(&resource), camel_to_kebab(&action)); + } + (camel_to_kebab(field_name), "execute".to_string()) +} + +fn split_at_second_word(name: &str) -> Option<(String, String)> { + let chars: Vec = name.chars().collect(); + for i in 1..chars.len() { + if chars[i].is_uppercase() { + let prefix = &name[..i]; + let suffix = &name[i..]; + if prefix.len() > 2 { + return Some((prefix.to_string(), suffix.to_string())); + } + } + } + None +} + +fn camel_to_kebab(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + let chars: Vec = s.chars().collect(); + for (i, &ch) in chars.iter().enumerate() { + if ch.is_uppercase() { + if i > 0 + && (chars[i - 1].is_lowercase() + || (i + 1 < chars.len() && chars[i + 1].is_lowercase())) + { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + result +} + +fn singular_kebab(kebab: &str) -> String { + let (prefix, last) = match kebab.rfind('-') { + Some(pos) => (&kebab[..pos + 1], &kebab[pos + 1..]), + None => ("", kebab), + }; + format!("{prefix}{}", singular_word(last)) +} + +fn singular_word(word: &str) -> String { + if let Some(stem) = word.strip_suffix("ies") { + if stem.len() >= 2 { + return format!("{stem}y"); + } + } + if word.ends_with("xes") + || word.ends_with("ches") + || word.ends_with("shes") + || word.ends_with("sses") + || word.ends_with("zzes") + { + if let Some(stem) = word.strip_suffix("es") { + if stem.len() >= 2 { + return stem.to_string(); + } + } + } + if let Some(stem) = word.strip_suffix('s') { + // Block Latin/Greek singulars that end in -us and must not be stripped. + const LATIN_SINGULARS: &[&str] = &[ + "status", "bonus", "campus", "census", "focus", + "nexus", "radius", "virus", "alias", + ]; + if stem.len() >= 3 && !stem.ends_with('s') && !LATIN_SINGULARS.contains(&word) { + return stem.to_string(); + } + } + word.to_string() +} + +fn singular_camel(name: &str) -> String { + singular_kebab(&camel_to_kebab(name)) +} + +fn param_to_flag_name(name: &str) -> String { + camel_to_kebab(name) +} + +/// Map GraphQL scalar type names to CLI param type strings. +fn graphql_type_to_param_type(type_name: &str) -> String { + match type_name { + "Int" => "integer".to_string(), + "Float" => "number".to_string(), + "Boolean" => "boolean".to_string(), + _ => "string".to_string(), + } +} + +/// Build CLI parameters and `GraphQLArgDef` list from introspection field arguments. +fn build_parameters_from_args( + args: &[Value], + input_types: &HashMap<&str, &Value>, + enum_types: &HashMap<&str, &Value>, + scalar_names: &[String], +) -> (HashMap, Vec) { + let mut params = HashMap::new(); + let mut arg_defs = Vec::new(); + + let is_known_complex = + |name: &str| input_types.contains_key(name) || enum_types.contains_key(name); + + for arg in args { + let arg_name = match arg["name"].as_str() { + Some(n) => n, + None => continue, + }; + let type_name = unwrap_type_name(&arg["type"]); + let is_required = is_non_null(&arg["type"]); + let flag_key = param_to_flag_name(arg_name); + + if is_scalar(&type_name, scalar_names) || !is_known_complex(&type_name) { + params.insert( + flag_key.clone(), + MethodParameter { + param_type: Some(graphql_type_to_param_type(&type_name)), + description: desc(arg), + required: is_required, + default: default_val(arg), + ..Default::default() + }, + ); + arg_defs.push(GraphQLArgDef { + name: arg_name.to_string(), + flag_key, + gql_type: gql_type_string(&arg["type"]), + is_input: false, + is_list: is_list_type(&arg["type"]), + }); + } else if let Some(enum_def) = enum_types.get(type_name.as_str()) { + let values = enum_values(enum_def); + params.insert( + flag_key.clone(), + MethodParameter { + param_type: Some("string".to_string()), + description: desc(arg), + required: is_required, + default: default_val(arg), + enum_values: Some(values), + ..Default::default() + }, + ); + arg_defs.push(GraphQLArgDef { + name: arg_name.to_string(), + flag_key, + gql_type: gql_type_string(&arg["type"]), + is_input: false, + is_list: is_list_type(&arg["type"]), + }); + } else if input_types.contains_key(type_name.as_str()) { + flatten_input_type( + &type_name, + arg_name, + "", + "", + is_required, + input_types, + enum_types, + scalar_names, + &mut params, + 0, + ); + arg_defs.push(GraphQLArgDef { + name: arg_name.to_string(), + flag_key, + gql_type: gql_type_string(&arg["type"]), + is_input: true, + is_list: is_list_type(&arg["type"]), + }); + } + } + + (params, arg_defs) +} + +const MAX_INPUT_DEPTH: u8 = 3; + +#[allow(clippy::too_many_arguments)] +fn flatten_input_type( + type_name: &str, + arg_name: &str, + field_path: &str, + flag_prefix: &str, + parent_required: bool, + input_types: &HashMap<&str, &Value>, + enum_types: &HashMap<&str, &Value>, + scalar_names: &[String], + params: &mut HashMap, + depth: u8, +) { + if depth >= MAX_INPUT_DEPTH { + return; + } + let input_def = match input_types.get(type_name) { + Some(d) => d, + None => return, + }; + let input_fields = match input_def["inputFields"].as_array() { + Some(f) => f, + None => return, + }; + + for input_field in input_fields { + let field_name = match input_field["name"].as_str() { + Some(n) => n, + None => continue, + }; + let field_type_name = unwrap_type_name(&input_field["type"]); + let field_required = parent_required && is_non_null(&input_field["type"]); + + let field_flag = param_to_flag_name(field_name); + let full_flag = if flag_prefix.is_empty() { + field_flag + } else { + format!("{flag_prefix}.{}", param_to_flag_name(field_name)) + }; + let full_path = if field_path.is_empty() { + field_name.to_string() + } else { + format!("{field_path}.{field_name}") + }; + + if is_scalar(&field_type_name, scalar_names) { + params.insert( + full_flag, + MethodParameter { + param_type: Some(graphql_type_to_param_type(&field_type_name)), + description: desc(input_field), + required: field_required, + default: default_val(input_field), + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(full_path), + ..Default::default() + }, + ); + } else if let Some(enum_def) = enum_types.get(field_type_name.as_str()) { + let values = enum_values(enum_def); + params.insert( + full_flag, + MethodParameter { + param_type: Some("string".to_string()), + description: desc(input_field), + required: field_required, + default: default_val(input_field), + enum_values: Some(values), + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(full_path), + }, + ); + } else if input_types.contains_key(field_type_name.as_str()) { + flatten_input_type( + &field_type_name, + arg_name, + &full_path, + &full_flag, + field_required, + input_types, + enum_types, + scalar_names, + params, + depth + 1, + ); + } else { + // Undeclared custom scalar — treat as string + params.insert( + full_flag, + MethodParameter { + param_type: Some("string".to_string()), + description: desc(input_field), + required: field_required, + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(full_path), + ..Default::default() + }, + ); + } + } +} + +/// Build a default selection set for a GraphQL return type. +fn build_default_selection( + type_name: &str, + object_types: &HashMap<&str, &Value>, + scalar_names: &[String], +) -> String { + if type_name.ends_with("Connection") { + let node_type = type_name.strip_suffix("Connection").unwrap(); + let node_selection = build_scalar_fields(node_type, object_types, scalar_names); + let nodes_part = if node_selection.is_empty() { + "nodes { id }".to_string() + } else { + format!("nodes {{ {node_selection} }}") + }; + return format!("{{ {nodes_part} pageInfo {{ hasNextPage endCursor }} }}"); + } + + if type_name.ends_with("Payload") { + if let Some(obj) = object_types.get(type_name) { + let mut parts = Vec::new(); + let fields = obj["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + for field in fields { + let args = field["args"].as_array().map(|a| a.len()).unwrap_or(0); + if args > 0 { + continue; + } + let field_name = field["name"].as_str().unwrap_or(""); + let ft = unwrap_type_name(&field["type"]); + if is_scalar(&ft, scalar_names) { + parts.push(field_name.to_string()); + } else if object_types.contains_key(ft.as_str()) { + let inner = build_scalar_fields(&ft, object_types, scalar_names); + if !inner.is_empty() { + parts.push(format!("{field_name} {{ {inner} }}")); + } + } + } + if parts.is_empty() { + return "{ success }".to_string(); + } + return format!("{{ {} }}", parts.join(" ")); + } + } + + let fields = build_scalar_fields(type_name, object_types, scalar_names); + if fields.is_empty() { + return "{ id }".to_string(); + } + format!("{{ {fields} }}") +} + +/// Build a space-separated scalar field selection for a type. +fn build_scalar_fields( + type_name: &str, + object_types: &HashMap<&str, &Value>, + scalar_names: &[String], +) -> String { + let obj = match object_types.get(type_name) { + Some(o) => o, + None => return String::new(), + }; + let fields = match obj["fields"].as_array() { + Some(f) => f, + None => return String::new(), + }; + + let mut parts: Vec = Vec::new(); + for f in fields { + let args_len = f["args"].as_array().map(|a| a.len()).unwrap_or(0); + if args_len > 0 { + continue; + } + let field_name = f["name"].as_str().unwrap_or(""); + let ft = unwrap_type_name(&f["type"]); + if is_scalar(&ft, scalar_names) { + parts.push(field_name.to_string()); + } else if !ft.ends_with("Connection") { + if let Some(inner_obj) = object_types.get(ft.as_str()) { + let inner_fields = inner_obj["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + let inner_scalars: Vec<&str> = inner_fields + .iter() + .filter(|if_| { + if_["args"].as_array().map(|a| a.len()).unwrap_or(0) == 0 + && is_scalar(&unwrap_type_name(&if_["type"]), scalar_names) + }) + .filter_map(|if_| if_["name"].as_str()) + .collect(); + if !inner_scalars.is_empty() { + parts.push(format!("{field_name} {{ {} }}", inner_scalars.join(" "))); + } + } + } + } + + parts.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // --------------------------------------------------------------------------- + // Naming utility tests (no schema needed) + // --------------------------------------------------------------------------- + + #[test] + fn test_camel_to_kebab() { + assert_eq!(camel_to_kebab("issueCreate"), "issue-create"); + assert_eq!(camel_to_kebab("issue"), "issue"); + assert_eq!(camel_to_kebab("customView"), "custom-view"); + assert_eq!(camel_to_kebab("attachmentsForURL"), "attachments-for-url"); + assert_eq!(camel_to_kebab("teamMembershipCreate"), "team-membership-create"); + } + + #[test] + fn test_singular_kebab() { + assert_eq!(singular_kebab("issues"), "issue"); + assert_eq!(singular_kebab("activities"), "activity"); + assert_eq!(singular_kebab("gift-card-activities"), "gift-card-activity"); + assert_eq!(singular_kebab("boxes"), "box"); + assert_eq!(singular_kebab("watches"), "watch"); + assert_eq!(singular_kebab("programs"), "program"); + assert_eq!(singular_kebab("loyalty-programs"), "loyalty-program"); + assert_eq!(singular_kebab("sms"), "sms"); + assert_eq!(singular_kebab("status"), "status"); + assert_eq!(singular_kebab("menus"), "menu"); + assert_eq!(singular_kebab("gurus"), "guru"); + } + + #[test] + fn test_split_query_name() { + assert_eq!( + split_query_name("issues", "IssueConnection"), + ("issue".to_string(), "list".to_string()) + ); + assert_eq!( + split_query_name("giftCardActivities", "GiftCardActivityConnection"), + ("gift-card-activity".to_string(), "list".to_string()) + ); + assert_eq!( + split_query_name("issue", "Issue"), + ("issue".to_string(), "get".to_string()) + ); + assert_eq!( + split_query_name("attachmentsForURL", "AttachmentConnection"), + ("attachment".to_string(), "list-for-url".to_string()) + ); + } + + #[test] + fn test_split_mutation_name() { + assert_eq!( + split_mutation_name("issueCreate"), + ("issue".to_string(), "create".to_string()) + ); + assert_eq!( + split_mutation_name("issueDelete"), + ("issue".to_string(), "delete".to_string()) + ); + assert_eq!( + split_mutation_name("attachmentLinkSlack"), + ("attachment".to_string(), "link-slack".to_string()) + ); + } + + // --------------------------------------------------------------------------- + // Schema loading helpers + // --------------------------------------------------------------------------- + + /// Shorthand type-ref builders for inline test schemas. + fn nn(inner: Value) -> Value { + json!({"kind": "NON_NULL", "name": null, "ofType": inner}) + } + fn scalar(name: &str) -> Value { + json!({"kind": "SCALAR", "name": name, "ofType": null}) + } + fn obj(name: &str) -> Value { + json!({"kind": "OBJECT", "name": name, "ofType": null}) + } + fn input_obj(name: &str) -> Value { + json!({"kind": "INPUT_OBJECT", "name": name, "ofType": null}) + } + fn list_of(inner: Value) -> Value { + json!({"kind": "LIST", "name": null, "ofType": inner}) + } + + fn make_schema(types: Value) -> String { + json!({ + "data": { + "__schema": { + "queryType": {"name": "Query"}, + "mutationType": {"name": "Mutation"}, + "types": types + } + } + }) + .to_string() + } + + // --------------------------------------------------------------------------- + // Schema loading tests + // --------------------------------------------------------------------------- + + #[test] + fn test_load_minimal_schema() { + let schema = make_schema(json!([ + { + "kind": "OBJECT", "name": "Query", + "fields": [ + { + "name": "issue", + "args": [{"name": "id", "type": nn(scalar("String"))}], + "type": nn(obj("Issue")), "isDeprecated": false + }, + { + "name": "issues", + "args": [ + {"name": "first", "type": scalar("Int")}, + {"name": "after", "type": scalar("String")} + ], + "type": nn(obj("IssueConnection")), "isDeprecated": false + } + ] + }, + { + "kind": "OBJECT", "name": "Mutation", + "fields": [ + { + "name": "issueCreate", + "args": [{"name": "input", "type": nn(input_obj("IssueCreateInput"))}], + "type": nn(obj("IssuePayload")), "isDeprecated": false + } + ] + }, + { + "kind": "OBJECT", "name": "Issue", + "fields": [ + {"name": "id", "args": [], "type": nn(scalar("ID")), "isDeprecated": false}, + {"name": "title", "args": [], "type": nn(scalar("String")), "isDeprecated": false}, + {"name": "description", "args": [], "type": scalar("String"), "isDeprecated": false} + ] + }, + { + "kind": "OBJECT", "name": "IssueConnection", + "fields": [ + {"name": "nodes", "args": [], "type": nn(list_of(nn(obj("Issue")))), "isDeprecated": false}, + {"name": "pageInfo", "args": [], "type": nn(obj("PageInfo")), "isDeprecated": false} + ] + }, + { + "kind": "OBJECT", "name": "PageInfo", + "fields": [ + {"name": "hasNextPage", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "endCursor", "args": [], "type": scalar("String"), "isDeprecated": false} + ] + }, + { + "kind": "OBJECT", "name": "IssuePayload", + "fields": [ + {"name": "success", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "issue", "args": [], "type": obj("Issue"), "isDeprecated": false} + ] + }, + { + "kind": "INPUT_OBJECT", "name": "IssueCreateInput", + "inputFields": [ + {"name": "title", "type": nn(scalar("String"))}, + {"name": "description", "type": scalar("String")}, + {"name": "teamId", "type": nn(scalar("String"))} + ] + } + ])); + + let doc = load_graphql_schema(&schema, "test", "https://api.example.com/graphql").unwrap(); + assert_eq!(doc.name, "test"); + + let issue = doc.resources.get("issue").expect("issue resource missing"); + assert!(issue.methods.contains_key("get"), "missing get"); + assert!(issue.methods.contains_key("list"), "missing list"); + assert!(issue.methods.contains_key("create"), "missing create"); + + let get = issue.methods.get("get").unwrap(); + assert!(get.parameters.contains_key("id")); + assert!(get.parameters.get("id").unwrap().required); + + let list = issue.methods.get("list").unwrap(); + let list_sel = &list.graphql.as_ref().unwrap().default_selection; + assert!(list_sel.contains("pageInfo"), "list selection missing pageInfo: {list_sel}"); + assert!(list_sel.contains("hasNextPage"), "list selection missing hasNextPage: {list_sel}"); + assert!(list_sel.contains("endCursor"), "list selection missing endCursor: {list_sel}"); + assert!(list.parameters.contains_key("after"), "missing --after flag"); + + let create = issue.methods.get("create").unwrap(); + assert!(create.parameters.contains_key("title")); + assert!(create.parameters.contains_key("description")); + assert!(create.parameters.contains_key("team-id")); + + let gql = get.graphql.as_ref().unwrap(); + assert_eq!(gql.operation_type, "query"); + assert_eq!(gql.field_name, "issue"); + + let gql_create = create.graphql.as_ref().unwrap(); + assert_eq!(gql_create.operation_type, "mutation"); + assert_eq!(gql_create.field_name, "issueCreate"); + } + + #[test] + fn test_nested_input_flattening() { + let schema = make_schema(json!([ + { + "kind": "OBJECT", "name": "Query", + "fields": [{ + "name": "search", + "args": [{"name": "filter", "type": nn(input_obj("SearchFilter"))}], + "type": nn(obj("SearchConnection")), "isDeprecated": false + }] + }, + { + "kind": "OBJECT", "name": "Mutation", + "fields": [] + }, + { + "kind": "OBJECT", "name": "SearchConnection", + "fields": [ + {"name": "nodes", "args": [], "type": nn(list_of(nn(obj("SearchResult")))), "isDeprecated": false}, + {"name": "pageInfo", "args": [], "type": nn(obj("PageInfo")), "isDeprecated": false} + ] + }, + {"kind": "OBJECT", "name": "SearchResult", "fields": [ + {"name": "id", "args": [], "type": nn(scalar("ID")), "isDeprecated": false} + ]}, + {"kind": "OBJECT", "name": "PageInfo", "fields": [ + {"name": "hasNextPage", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "endCursor", "args": [], "type": scalar("String"), "isDeprecated": false} + ]}, + { + "kind": "INPUT_OBJECT", "name": "SearchFilter", + "inputFields": [ + {"name": "query", "type": scalar("String")}, + {"name": "dateRange", "type": input_obj("DateRangeInput")}, + {"name": "minAmount", "type": scalar("Int")} + ] + }, + { + "kind": "INPUT_OBJECT", "name": "DateRangeInput", + "inputFields": [ + {"name": "start", "type": nn(scalar("String"))}, + {"name": "end", "type": nn(scalar("String"))} + ] + } + ])); + + let doc = load_graphql_schema(&schema, "test", "https://api.example.com/graphql").unwrap(); + let all_params: Vec = doc + .resources.values() + .flat_map(|r| r.methods.values()) + .flat_map(|m| m.parameters.keys().cloned()) + .collect(); + + assert!(all_params.iter().any(|k| k == "query"), "missing top-level query param: {all_params:?}"); + assert!(all_params.iter().any(|k| k.contains("start")), "missing dateRange.start: {all_params:?}"); + assert!(all_params.iter().any(|k| k.contains("end")), "missing dateRange.end: {all_params:?}"); + } + + #[test] + fn test_undeclared_scalar_as_arg() { + let schema = make_schema(json!([ + { + "kind": "OBJECT", "name": "Query", + "fields": [{ + "name": "orders", + "args": [ + {"name": "after", "type": json!({"kind": "SCALAR", "name": "Cursor", "ofType": null})}, + {"name": "first", "type": scalar("Int")} + ], + "type": nn(obj("OrderConnection")), "isDeprecated": false + }] + }, + {"kind": "OBJECT", "name": "Mutation", "fields": []}, + {"kind": "OBJECT", "name": "OrderConnection", "fields": [ + {"name": "nodes", "args": [], "type": nn(list_of(nn(obj("Order")))), "isDeprecated": false}, + {"name": "pageInfo", "args": [], "type": nn(obj("PageInfo")), "isDeprecated": false} + ]}, + {"kind": "OBJECT", "name": "Order", "fields": [ + {"name": "id", "args": [], "type": nn(scalar("ID")), "isDeprecated": false} + ]}, + {"kind": "OBJECT", "name": "PageInfo", "fields": [ + {"name": "hasNextPage", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "endCursor", "args": [], "type": scalar("String"), "isDeprecated": false} + ]} + ])); + + let doc = load_graphql_schema(&schema, "test", "https://api.example.com/graphql").unwrap(); + let all_params: Vec = doc + .resources.values() + .flat_map(|r| r.methods.values()) + .flat_map(|m| m.parameters.keys().cloned()) + .collect(); + assert!( + all_params.iter().any(|k| k == "after"), + "undeclared Cursor scalar should produce --after flag: {all_params:?}" + ); + } + +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/hooks.rs b/seed/cli/query-parameters-openapi/github-npm/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/hooks.rs @@ -0,0 +1,297 @@ +//! Path-addressed hook registries for the root [`CliApp`]. +//! +//! Hooks are registered against glob-style paths in the command tree +//! (e.g. `&["users", "**"]` fires for every operation under `users`). +//! The registry stores boxed async callbacks and matches them at +//! dispatch time. + +use serde_json::Value; + +use crate::binding::BoxFuture; +use crate::error::CliError; + +// ── Pattern matching ──────────────────────────────────────────────── + +/// A compiled path pattern. Segments are literal strings; `*` matches +/// one segment; `**` matches zero or more segments. +#[derive(Debug, Clone)] +pub struct PathPattern { + segments: Vec, +} + +#[derive(Debug, Clone)] +enum PatternSegment { + Literal(String), + Single, // * + Globstar, // ** +} + +impl PathPattern { + pub fn new(segments: &[&str]) -> Self { + Self { + segments: segments + .iter() + .map(|s| match *s { + "**" => PatternSegment::Globstar, + "*" => PatternSegment::Single, + lit => PatternSegment::Literal(lit.to_string()), + }) + .collect(), + } + } + + /// Returns `true` if `path` matches this pattern. + pub fn matches(&self, path: &[String]) -> bool { + Self::do_match(&self.segments, path) + } + + fn do_match(pattern: &[PatternSegment], path: &[String]) -> bool { + match (pattern.first(), path.first()) { + (None, None) => true, + (None, Some(_)) => false, + (Some(PatternSegment::Globstar), _) => { + // ** can match zero segments (skip globstar) or one + // segment (consume one path element, keep globstar). + Self::do_match(&pattern[1..], path) + || (!path.is_empty() && Self::do_match(pattern, &path[1..])) + } + (Some(_), None) => { + // Remaining pattern segments with no path left — only + // matches if all remaining are globstars. + pattern.iter().all(|s| matches!(s, PatternSegment::Globstar)) + } + (Some(PatternSegment::Literal(lit)), Some(seg)) => { + lit == seg && Self::do_match(&pattern[1..], &path[1..]) + } + (Some(PatternSegment::Single), Some(_)) => { + Self::do_match(&pattern[1..], &path[1..]) + } + } + } +} + +// ── Hook storage ──────────────────────────────────────────────────── + +/// A `transform_response` callback: `(Value, op_path) -> Result`. +pub type TransformResponseFn = + Box) -> BoxFuture<'static, Result> + Send + Sync>; + +/// A `recover_error` callback: `(CliError, op_path) -> Result>`. +/// Returning `Ok(Some(v))` short-circuits with `v` as the response; +/// `Ok(None)` lets the error propagate to the next hook or default path. +pub type RecoverErrorFn = Box< + dyn Fn(CliError, Vec) -> BoxFuture<'static, Result, CliError>> + + Send + + Sync, +>; + +/// A path-addressed hook entry. +pub(crate) struct HookEntry { + pattern: PathPattern, + callback: F, +} + +/// Registry of spec-level hooks registered on the root `CliApp`. +#[derive(Default)] +pub struct HookRegistry { + transform_response: Vec>, + recover_error: Vec>, +} + +impl HookRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn add_transform_response(&mut self, path: &[&str], f: TransformResponseFn) { + self.transform_response.push(HookEntry { + pattern: PathPattern::new(path), + callback: f, + }); + } + + pub fn add_recover_error(&mut self, path: &[&str], f: RecoverErrorFn) { + self.recover_error.push(HookEntry { + pattern: PathPattern::new(path), + callback: f, + }); + } + + /// Run matching `transform_response` hooks in registration order. + pub async fn run_transform_response( + &self, + mut value: Value, + op_path: &[String], + ) -> Result { + for entry in &self.transform_response { + if entry.pattern.matches(op_path) { + value = (entry.callback)(value, op_path.to_vec()).await?; + } + } + Ok(value) + } + + /// Run matching `recover_error` hooks in registration order. + /// First `Ok(Some(v))` wins; `Ok(None)` defers to the next hook. + /// + /// The original error is duplicated before being passed to each + /// hook, so declining hooks (`Ok(None)`) do not destroy the error + /// for subsequent hooks or the final error path. + pub async fn run_recover_error( + &self, + err: CliError, + op_path: &[String], + ) -> Result { + let mut current_err = err; + for entry in &self.recover_error { + if entry.pattern.matches(op_path) { + // Duplicate before passing to the callback so the + // original is preserved if the hook declines. + let err_for_hook = current_err.duplicate(); + match (entry.callback)(err_for_hook, op_path.to_vec()).await { + Ok(Some(value)) => return Ok(value), + Ok(None) => { + // Hook declined — original error preserved + // via duplicate() above; current_err unchanged. + } + Err(new_err) => { + current_err = new_err; + } + } + } + } + Err(current_err) + } + + pub fn is_empty(&self) -> bool { + self.transform_response.is_empty() && self.recover_error.is_empty() + } + + /// Returns `true` if at least one `recover_error` hook is registered. + pub fn has_recover_error(&self) -> bool { + !self.recover_error.is_empty() + } + + /// Validate that every registered hook pattern matches at least one + /// leaf command in the given command tree. Returns an error listing + /// all unmatched patterns. + pub fn validate_patterns(&self, cmd: &clap::Command) -> Result<(), crate::error::CliError> { + if self.is_empty() { + return Ok(()); + } + let leaves = collect_leaf_paths(cmd, &mut Vec::new()); + let mut unmatched = Vec::new(); + for entry in &self.transform_response { + if !leaves.iter().any(|leaf| entry.pattern.matches(leaf)) { + unmatched.push(format!( + "transform_response pattern {:?} matches no operations", + pattern_to_strings(&entry.pattern), + )); + } + } + for entry in &self.recover_error { + if !leaves.iter().any(|leaf| entry.pattern.matches(leaf)) { + unmatched.push(format!( + "recover_error pattern {:?} matches no operations", + pattern_to_strings(&entry.pattern), + )); + } + } + if unmatched.is_empty() { + Ok(()) + } else { + Err(crate::error::CliError::Validation(unmatched.join("; "))) + } + } +} + +/// Recursively collect all leaf command paths (commands with no +/// subcommands). Includes hidden commands so that `.hide()` followed by +/// a hook on the hidden path does not produce a false validation error. +fn collect_leaf_paths(cmd: &clap::Command, prefix: &mut Vec) -> Vec> { + let subs: Vec<_> = cmd.get_subcommands().collect(); + if subs.is_empty() { + return vec![prefix.clone()]; + } + let mut leaves = Vec::new(); + for sub in subs { + let name = sub.get_name().to_string(); + // Skip built-in utility commands and binding-internal + // subcommands that bypass the hook pipeline. + if name == "help" || name == "completion" || name == "man" + || name == "generate-skills" + { + continue; + } + prefix.push(name); + leaves.extend(collect_leaf_paths(sub, prefix)); + prefix.pop(); + } + leaves +} + +/// Extract display-friendly strings from a pattern for error messages. +fn pattern_to_strings(pattern: &PathPattern) -> Vec { + pattern.segments.iter().map(|s| match s { + PatternSegment::Literal(lit) => lit.clone(), + PatternSegment::Single => "*".to_string(), + PatternSegment::Globstar => "**".to_string(), + }).collect() +} + +// ── Tests ─────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pattern_exact_match() { + let p = PathPattern::new(&["users", "get"]); + assert!(p.matches(&["users".into(), "get".into()])); + assert!(!p.matches(&["users".into()])); + assert!(!p.matches(&["users".into(), "get".into(), "extra".into()])); + } + + #[test] + fn pattern_single_wildcard() { + let p = PathPattern::new(&["users", "*"]); + assert!(p.matches(&["users".into(), "get".into()])); + assert!(p.matches(&["users".into(), "list".into()])); + assert!(!p.matches(&["users".into()])); + assert!(!p.matches(&["users".into(), "get".into(), "extra".into()])); + } + + #[test] + fn pattern_globstar() { + let p = PathPattern::new(&["**"]); + assert!(p.matches(&[])); + assert!(p.matches(&["users".into()])); + assert!(p.matches(&["users".into(), "get".into()])); + } + + #[test] + fn pattern_globstar_prefix() { + let p = PathPattern::new(&["users", "**"]); + assert!(p.matches(&["users".into()])); + assert!(p.matches(&["users".into(), "get".into()])); + assert!(p.matches(&["users".into(), "a".into(), "b".into()])); + assert!(!p.matches(&["posts".into()])); + } + + #[test] + fn pattern_globstar_suffix() { + let p = PathPattern::new(&["**", "list"]); + assert!(p.matches(&["list".into()])); + assert!(p.matches(&["users".into(), "list".into()])); + assert!(p.matches(&["a".into(), "b".into(), "list".into()])); + assert!(!p.matches(&["users".into(), "get".into()])); + } + + #[test] + fn pattern_empty() { + let p = PathPattern::new(&[]); + assert!(p.matches(&[])); + assert!(!p.matches(&["a".into()])); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/http.rs b/seed/cli/query-parameters-openapi/github-npm/src/http.rs new file mode 100644 index 000000000000..4d7ee2f14ef2 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/http.rs @@ -0,0 +1,845 @@ +//! HTTP client construction and TLS-error diagnostics. +//! +//! [`HttpConfig`] holds the inputs that go into building a [`reqwest::Client`] +//! for a CLI: the binary name (used to scope env vars) and any compile-time +//! trust roots a binary author baked in via `CliApp::extra_root_cert`. +//! +//! [`HttpConfig::build_client`] honors a small set of environment variables +//! so users can adapt TLS / proxy behavior without rebuilding the CLI. +//! Variables are prefixed with `_` (the CLI's name uppercased with `-` +//! mapped to `_`). +//! +//! | Variable | Effect | +//! | --------------------------------- | --------------------------------------------------- | +//! | `_CA_BUNDLE` | Path to PEM file appended to the default trust roots. Generic fallback: `SSL_CERT_FILE`. | +//! | `_INSECURE` = `1`/`true`/`yes` | Disable TLS verification (with a one-time stderr warning). | +//! | `_PROXY` | HTTP(S) proxy URL — replaces `HTTPS_PROXY`/`HTTP_PROXY` for this CLI. Pair with `_NO_PROXY` for a scoped bypass list, or rely on the global `NO_PROXY` (used as a fallback when `_NO_PROXY` is unset). | +//! | `_TIMEOUT_SECS` | Total request timeout in seconds (default: no timeout). | +//! | `_CONNECT_TIMEOUT_SECS` | Connection-establishment timeout in seconds. | +//! +//! Aliases: `_EXTRA_CA_CERTS` (= `_CA_BUNDLE`), +//! `_INSECURE_SKIP_VERIFY` (= `_INSECURE`). +//! +//! `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` are honored by reqwest's defaults +//! when the scoped overrides are absent. +//! +//! ## Configuration timing +//! +//! Compile-time roots passed via [`HttpConfig::with_extra_root_cert`] are +//! captured once when the config is built. Env vars are re-read on every +//! [`HttpConfig::build_client`] call, so a long-running consumer that +//! rebuilds the client picks up env changes for `_INSECURE`, `_PROXY`, etc. +//! For one-shot CLI use this distinction doesn't matter. + +use std::collections::HashSet; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; + +use crate::error::CliError; + +// ---------------------------------------------------------------------------- +// HttpConfig — the SDK's HTTP layer configuration +// ---------------------------------------------------------------------------- + +/// Configuration for building HTTP clients on behalf of a named CLI. +/// +/// Holds the binary name (which scopes env-var lookups) and any compile-time +/// trust roots the binary author registered. `CliApp::run` builds one once +/// and threads it through to the executor. +#[derive(Clone, Debug)] +pub struct HttpConfig { + /// CLI binary name (e.g. `"myapi"`). Cheap to clone via `Arc`. + name: Arc, + /// Env-var prefix derived once from `name`: uppercase + `-` → `_`. Cached + /// so the transform isn't recomputed on every `build_client` call (and + /// so external callers can't forget the `-` substitution). + prefix: Arc, + /// Trust roots baked in at compile time. We store parsed `Certificate`s + /// (not raw PEM bytes) so `build_client` doesn't re-parse — and so a + /// later `build_client` failure can only come from runtime input. + extra_root_certs: Vec, + /// Raw PEM bytes for each compile-time trust root, kept alongside the + /// parsed `reqwest::Certificate` above. Required by transport-neutral + /// consumers (`resolve()`) that build their own TLS connectors (e.g. + /// `tokio-tungstenite` for WebSockets) and need to feed PEM in rather + /// than reqwest-typed certs. Each `Vec` is the raw bytes supplied + /// to [`HttpConfig::with_extra_root_cert`]. + extra_root_certs_pem: Vec>, +} + +/// Transport-neutral view of the resolved HTTP/TLS configuration. +/// +/// Returned by [`HttpConfig::resolve`]. Holds compile-time roots, the +/// env-var-resolved CA bundle (if any), insecure-skip-verify flag, proxy +/// settings, and timeouts. Lets non-reqwest transports (e.g. WebSocket via +/// `tokio-tungstenite`, future SSE / gRPC) build their own clients while +/// honouring the same `_*` env vars users already configure for the +/// reqwest path. +/// +/// The reqwest path in [`HttpConfig::build_client`] reads env vars +/// independently for historical reasons — keep both readers in sync. The +/// `resolved_matches_build_client` test asserts agreement on the subset +/// that's representable in both shapes. +#[derive(Debug, Clone)] +pub struct ResolvedTlsConfig { + /// Raw PEM bytes of all trust roots — compile-time first, then the + /// env-resolved bundle (if any). Order matches the order they would be + /// added to a `reqwest::ClientBuilder` via `add_root_certificate`. + pub extra_root_certs_pem: Vec>, + /// `_INSECURE=1` / `_INSECURE_SKIP_VERIFY=1` was set. + /// Transports honoring this should disable cert+hostname verification. + pub insecure_skip_verify: bool, + /// `_PROXY=` was set. Transports that support HTTP proxying + /// should route through this URL. The `no_proxy` field carries either + /// `_NO_PROXY` or the fallback `NO_PROXY`, matching the reqwest + /// path's bypass-list resolution. + pub proxy: Option, + /// `_CONNECT_TIMEOUT_SECS` if set. Bound on socket establishment. + pub connect_timeout: Option, + /// `_TIMEOUT_SECS` if set. Bound on total request lifetime + /// (reqwest semantics); for streaming transports (WebSocket), use as a + /// handshake-only deadline since the connection lifetime is unbounded. + pub request_timeout: Option, +} + +/// Resolved proxy override, as parsed from `_PROXY` / `_NO_PROXY`. +#[derive(Debug, Clone)] +pub struct ResolvedProxy { + /// Proxy URL (`http://...` or `https://...`). + pub url: String, + /// Bypass list — either `_NO_PROXY` (if set) or the fallback + /// `NO_PROXY` env var. `None` means honor the standard reqwest defaults. + pub no_proxy: Option, +} + +impl HttpConfig { + /// Create a config for the given CLI name. Empty names are rejected — + /// they would silently disable the entire env-var scoping system. + pub fn new(name: impl Into) -> Result { + let name = name.into(); + if name.is_empty() { + return Err(CliError::Other(anyhow::anyhow!( + "HttpConfig::new called with empty name — \ + env-var scoping requires a non-empty CLI name" + ))); + } + let prefix: Arc = Arc::from(name.to_uppercase().replace('-', "_")); + Ok(Self { + name: Arc::from(name), + prefix, + extra_root_certs: Vec::new(), + extra_root_certs_pem: Vec::new(), + }) + } + + /// Append a PEM-encoded trust root that this CLI will accept on top of + /// the system's default roots. Typically called via `CliApp::extra_root_cert`. + /// Returns an error if the PEM is unparseable or contains zero certs. + pub fn with_extra_root_cert(mut self, pem: &[u8]) -> Result { + // Validate the PEM up front (`parse_extra_root_cert` rejects empty / + // unparseable bundles). Storing the raw bytes alongside lets + // non-reqwest transports build their own connectors without + // re-parsing through reqwest types. + self.extra_root_certs.extend(parse_extra_root_cert(pem)?); + self.extra_root_certs_pem.push(pem.to_vec()); + Ok(self) + } + + /// Append already-parsed trust roots. Used internally by `CliApp` to + /// thread compile-time roots from the builder into the runtime config + /// without re-parsing. The matching PEM bytes must be supplied so + /// `resolve()` can hand them to non-reqwest transports. + pub(crate) fn with_parsed_root_certs( + mut self, + certs: impl IntoIterator, + pem_bytes: impl IntoIterator>, + ) -> Self { + self.extra_root_certs.extend(certs); + self.extra_root_certs_pem.extend(pem_bytes); + self + } + + /// CLI binary name (e.g. `"myapi"`). + pub fn name(&self) -> &str { + &self.name + } + + /// Env-var prefix derived from the binary name (uppercase, `-` → `_`). + /// `MYAPI`, `OTHER_API`, etc. Use this when constructing scoped env vars + /// so the transform stays consistent across the codebase. + pub fn env_prefix(&self) -> &str { + &self.prefix + } + + /// Resolve the transport-neutral view of this config: compile-time + /// trust roots concatenated with the env-resolved `_CA_BUNDLE`, + /// the `_INSECURE` flag, the proxy override, and timeouts. + /// + /// Used by non-reqwest transports (`fern_cli_sdk::websocket`, future + /// SSE / raw-socket consumers) that need to build their own TLS + /// connector while honoring the same `_*` env vars users + /// already configure for the reqwest path. + /// + /// Reads env vars at call time. Reading the CA bundle file can fail + /// (missing / unparseable / no PEM certs) — those errors surface here + /// rather than getting swallowed by the transport's own connect path. + /// + /// Side effects mirror [`HttpConfig::build_client`]: emits the + /// `_INSECURE` warning at most once per (binary, process). No + /// network calls. + pub fn resolve(&self) -> Result { + let prefix = &self.prefix; + + let mut extra_root_certs_pem: Vec> = self.extra_root_certs_pem.clone(); + if let Some(path) = first_env([ + scoped(prefix, "_CA_BUNDLE"), + scoped(prefix, "_EXTRA_CA_CERTS"), + "SSL_CERT_FILE".to_string(), + ]) { + let pem = std::fs::read(&path).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "failed to read CA bundle from {path}: {e}" + )) + })?; + // Validate the bundle here so transport callers don't have to + // re-implement the "empty / non-PEM" diagnostic. We parse but + // discard the certs — the raw bytes are what we hand back. + let source = format!("CA bundle at {path}"); + let _ = parse_pem_bundle(&pem, &source)?; + extra_root_certs_pem.push(pem); + } + + let insecure_skip_verify = if let Some(active_key) = first_env_truthy([ + scoped(prefix, "_INSECURE"), + scoped(prefix, "_INSECURE_SKIP_VERIFY"), + ]) { + warn_insecure_once(&self.name, &active_key); + true + } else { + false + }; + + let proxy = first_env([scoped(prefix, "_PROXY")]).map(|url| { + // Mirror the reqwest path's bypass-list resolution: _NO_PROXY + // wins when set, otherwise fall back to the standard NO_PROXY env. + let no_proxy = first_env([scoped(prefix, "_NO_PROXY")]) + .or_else(|| first_env(["NO_PROXY".to_string()])); + ResolvedProxy { url, no_proxy } + }); + + let connect_timeout = parse_secs(&scoped(prefix, "_CONNECT_TIMEOUT_SECS")) + .map(Duration::from_secs); + let request_timeout = parse_secs(&scoped(prefix, "_TIMEOUT_SECS")) + .map(Duration::from_secs); + + Ok(ResolvedTlsConfig { + extra_root_certs_pem, + insecure_skip_verify, + proxy, + connect_timeout, + request_timeout, + }) + } + + /// Build an HTTP client, applying compile-time roots, env-var overrides, + /// proxy settings, and timeouts. Reads `_*` env vars at call time; + /// compile-time roots were captured when this config was built. + pub fn build_client(&self) -> Result { + let prefix = &self.prefix; + + let mut builder = reqwest::Client::builder(); + let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + if let Ok(header_value) = HeaderValue::from_str(&user_agent) { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, header_value); + builder = builder.default_headers(headers); + } + + // --- Compile-time trust roots (from CliApp::extra_root_cert) --- + for cert in &self.extra_root_certs { + builder = builder.add_root_certificate(cert.clone()); + } + + // --- Runtime trust roots from env --- + if let Some(path) = first_env([ + scoped(prefix, "_CA_BUNDLE"), + scoped(prefix, "_EXTRA_CA_CERTS"), + "SSL_CERT_FILE".to_string(), + ]) { + let pem = std::fs::read(&path).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "failed to read CA bundle from {path}: {e}" + )) + })?; + let source = format!("CA bundle at {path}"); + for cert in parse_pem_bundle(&pem, &source)? { + builder = builder.add_root_certificate(cert); + } + } + + // --- Insecure mode (opt-in, loud) --- + if let Some(active_key) = first_env_truthy([ + scoped(prefix, "_INSECURE"), + scoped(prefix, "_INSECURE_SKIP_VERIFY"), + ]) { + warn_insecure_once(&self.name, &active_key); + builder = builder + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true); + } + + // --- Proxy override --- + // + // Reqwest's default behavior reads `HTTPS_PROXY` / `HTTP_PROXY` and + // adds them automatically. Adding our explicit `.proxy(...)` on top + // would result in *both* being tried in order — the env-detected one + // first. So when `_PROXY` is set, we clear reqwest's + // auto-detection with `.no_proxy()` first, then add ours. + // + // Bypass-list semantics: `_PROXY` *replaces* the global + // `HTTPS_PROXY`/`HTTP_PROXY`, but the bypass list is *augmenting*: + // - if `_NO_PROXY` is set, it's used (global NO_PROXY ignored); + // - otherwise, the standard `NO_PROXY` is honored as a fallback so + // a user who only set the shell-wide bypass list doesn't lose it. + // Standalone `_NO_PROXY` (without `_PROXY`) is *not* + // honored — it would have ambiguous semantics (override which proxy?). + let proxy_key = scoped(prefix, "_PROXY"); + if let Some(url) = first_env([proxy_key.clone()]) { + let mut proxy = reqwest::Proxy::all(&url).map_err(|e| { + CliError::Other(anyhow::anyhow!("invalid {proxy_key}={url}: {e}")) + })?; + if let Some(list) = first_env([scoped(prefix, "_NO_PROXY")]) { + if let Some(np) = reqwest::NoProxy::from_string(&list) { + proxy = proxy.no_proxy(Some(np)); + } + } else if let Some(np) = reqwest::NoProxy::from_env() { + proxy = proxy.no_proxy(Some(np)); + } + builder = builder.no_proxy().proxy(proxy); + } + + // --- Timeouts --- + if let Some(secs) = parse_secs(&scoped(prefix, "_TIMEOUT_SECS")) { + builder = builder.timeout(std::time::Duration::from_secs(secs)); + } + if let Some(secs) = parse_secs(&scoped(prefix, "_CONNECT_TIMEOUT_SECS")) { + builder = builder.connect_timeout(std::time::Duration::from_secs(secs)); + } + + builder.build().map_err(|e| { + CliError::Other(anyhow::anyhow!("failed to build HTTP client: {e}")) + }) + } +} + +/// Parse a PEM bundle into trust-root certs, with the SDK's standard +/// validation: empty bytes / no PEM headers / unparseable bytes all surface +/// as errors. `source` is woven into error messages so users can tell where +/// the bad PEM came from (`"extra root cert"`, `"CA bundle at /path/..."`). +fn parse_pem_bundle(pem: &[u8], source: &str) -> Result, CliError> { + let certs = reqwest::Certificate::from_pem_bundle(pem).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "failed to parse {source}: {e} — check the bytes are valid PEM-encoded certificates" + )) + })?; + if certs.is_empty() { + return Err(CliError::Other(anyhow::anyhow!( + "{source} contains no PEM certificates — check the bytes are PEM-encoded" + ))); + } + Ok(certs) +} + +/// Convenience wrapper for the compile-time path. Used by +/// [`HttpConfig::with_extra_root_cert`] and `CliApp::extra_root_cert`. +pub(crate) fn parse_extra_root_cert(pem: &[u8]) -> Result, CliError> { + parse_pem_bundle(pem, "extra root cert") +} + +// ---------------------------------------------------------------------------- +// TLS error diagnostics +// ---------------------------------------------------------------------------- + +/// If the given reqwest error looks like a TLS chain failure, print a hint +/// to stderr telling the user how to fix it (export `_CA_BUNDLE`, +/// unset `HTTPS_PROXY`, or use `_INSECURE=1` for debugging). +/// +/// Emits at most once per (binary, process) so paginated callers don't spam. +pub(crate) fn maybe_emit_tls_hint(cfg: &HttpConfig, err: &reqwest::Error) { + if !looks_like_tls_failure(err) { + return; + } + if !is_first_emission(&cfg.name, "tls") { + return; + } + let prefix = cfg.env_prefix(); + eprintln!( + "hint: TLS chain validation failed. If you're behind a corporate proxy or \ + interception tool (Proxyman, Charles, mitmproxy):\n \ + export {prefix}_CA_BUNDLE=/path/to/ca.pem # trust an extra root\n \ + export SSL_CERT_FILE=/path/to/ca.pem # generic fallback\n \ + {prefix}_INSECURE=1 # skip verification (debugging only)" + ); +} + +/// Detect whether a reqwest error is plausibly a TLS chain failure. Uses the +/// typed `is_connect()` predicate plus a deliberately-broad substring match +/// against `"certificate"` in the rendered error chain. We accept some false +/// positives (the hint is benign when wrong) in exchange for not missing real +/// TLS failures when reqwest's error wording shifts between versions. +fn looks_like_tls_failure(err: &reqwest::Error) -> bool { + if !err.is_connect() { + return false; + } + // `{:#}` prints the full source chain — TLS errors are usually wrapped + // several layers deep, with the actual word appearing near the bottom. + format!("{err:#}").to_lowercase().contains("certificate") +} + +/// Print the insecure-mode warning at most once per (binary, process). +fn warn_insecure_once(name: &str, active_key: &str) { + if !is_first_emission(name, "insecure") { + return; + } + eprintln!( + "warning: TLS verification disabled via {active_key} — \ + requests are vulnerable to MITM. Unset for production use." + ); +} + +/// Returns true the *first* time a (binary, kind) pair is seen in this +/// process, false thereafter. Lets us print one-shot warnings/hints without +/// silencing them across multiple binaries built on the SDK in the same +/// process (e.g. test harnesses, library consumers wiring up two CLIs). +fn is_first_emission(name: &str, kind: &str) -> bool { + static EMITTED: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + let mut guard = EMITTED.lock().unwrap_or_else(|e| e.into_inner()); + guard.insert(format!("{name}::{kind}")) +} + +// ---------------------------------------------------------------------------- +// Env-var helpers +// ---------------------------------------------------------------------------- + +/// Format a scoped env-var name. `scoped("MYAPI", "_CA_BUNDLE")` → +/// `"MYAPI_CA_BUNDLE"`. +fn scoped(prefix: &str, suffix: &str) -> String { + format!("{prefix}{suffix}") +} + +/// Return the first non-empty env var value among the given keys, in order. +fn first_env>(keys: impl IntoIterator) -> Option { + keys.into_iter().find_map(|k| { + let k = k.as_ref(); + if k.is_empty() { + return None; + } + std::env::var(k).ok().filter(|v| !v.is_empty()) + }) +} + +/// Like `first_env`, but checks for truthy values and returns the *name* of +/// the env var that fired so warnings can name the actual variable the user +/// set. +fn first_env_truthy>(keys: impl IntoIterator) -> Option { + keys.into_iter().find_map(|k| { + let k = k.as_ref(); + if k.is_empty() { + return None; + } + match std::env::var(k) { + Ok(v) if is_truthy(&v) => Some(k.to_string()), + _ => None, + } + }) +} + +fn is_truthy(v: &str) -> bool { + v.eq_ignore_ascii_case("1") + || v.eq_ignore_ascii_case("true") + || v.eq_ignore_ascii_case("yes") + || v.eq_ignore_ascii_case("on") +} + +fn parse_secs(key: &str) -> Option { + std::env::var(key).ok().and_then(|v| v.parse().ok()) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// RAII guard that sets env vars on construction and unsets them on + /// drop, so a panic mid-test doesn't leak mutations into other tests. + /// The `unset` helper additionally restores any pre-existing value + /// on drop so the guard works for both setting and clearing. + #[derive(Default)] + struct EnvGuard { + set_keys: Vec, + unset_keys: Vec<(String, Option)>, + } + + impl EnvGuard { + fn set(&mut self, k: &str, v: impl AsRef) { + std::env::set_var(k, v); + self.set_keys.push(k.to_string()); + } + /// Temporarily clear `k` for the duration of the guard, restoring + /// any previously-set value on drop. Used to isolate tests from + /// ambient CI/local env (e.g. Linux runners that set + /// `SSL_CERT_FILE` globally — that var would otherwise leak into + /// `HttpConfig::resolve`'s CA-bundle fallback). + fn unset(&mut self, k: &str) { + let prior = std::env::var_os(k); + std::env::remove_var(k); + self.unset_keys.push((k.to_string(), prior)); + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for k in &self.set_keys { + std::env::remove_var(k); + } + for (k, prior) in &self.unset_keys { + if let Some(v) = prior { + std::env::set_var(k, v); + } else { + std::env::remove_var(k); + } + } + } + } + + /// Standard env-isolation for `resolve()` tests — clears the + /// CA-bundle fallback chain that CI may have pre-populated. Use at + /// the top of any test that asserts on a clean / minimal resolved + /// config. + fn isolated_env_guard() -> EnvGuard { + let mut g = EnvGuard::default(); + g.unset("SSL_CERT_FILE"); + g + } + + #[test] + #[serial_test::serial] + fn build_client_succeeds_with_clean_env() { + let cfg = HttpConfig::new("myapi").unwrap(); + assert!(cfg.build_client().is_ok()); + } + + #[test] + fn http_config_rejects_empty_name() { + let err = HttpConfig::new("").expect_err("empty name should error"); + assert!(err.to_string().contains("empty name")); + } + + #[test] + fn env_prefix_uppercases_and_translates_dashes() { + let cfg = HttpConfig::new("openapi-fixture").unwrap(); + assert_eq!(cfg.env_prefix(), "OPENAPI_FIXTURE"); + assert_eq!(cfg.name(), "openapi-fixture"); + } + + #[test] + fn with_extra_root_cert_rejects_non_pem() { + let cfg = HttpConfig::new("regtest").unwrap(); + let err = cfg + .with_extra_root_cert(b"not a pem") + .expect_err("non-PEM should error"); + assert!(err.to_string().contains("extra root cert")); + } + + #[test] + fn with_extra_root_cert_rejects_empty_bundle() { + let cfg = HttpConfig::new("regtest").unwrap(); + let err = cfg + .with_extra_root_cert(b"") + .expect_err("empty bytes should error"); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("empty") || msg.contains("no pem")); + } + + #[test] + #[serial_test::serial] + fn first_env_truthy_returns_active_key_name() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_ACTIVE_PRIMARY", "1"); + env.set("CLI_TEST_ACTIVE_ALIAS", "true"); + let primary = "CLI_TEST_ACTIVE_PRIMARY".to_string(); + let alias = "CLI_TEST_ACTIVE_ALIAS".to_string(); + assert_eq!( + first_env_truthy([&primary, &alias]).as_deref(), + Some("CLI_TEST_ACTIVE_PRIMARY"), + ); + std::env::remove_var("CLI_TEST_ACTIVE_PRIMARY"); + // Alias wins now that primary is unset. + assert_eq!( + first_env_truthy([&primary, &alias]).as_deref(), + Some("CLI_TEST_ACTIVE_ALIAS"), + ); + } + + #[test] + #[serial_test::serial] + fn first_env_truthy_rejects_falsy() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_FALSY", "0"); + let key = "CLI_TEST_FALSY".to_string(); + assert!(first_env_truthy([&key]).is_none()); + env.set("CLI_TEST_FALSY", "false"); + assert!(first_env_truthy([&key]).is_none()); + env.set("CLI_TEST_FALSY", ""); + assert!(first_env_truthy([&key]).is_none()); + } + + #[test] + fn is_truthy_is_case_insensitive() { + assert!(is_truthy("1")); + assert!(is_truthy("TRUE")); + assert!(is_truthy("True")); + assert!(is_truthy("yes")); + assert!(is_truthy("ON")); + assert!(!is_truthy("0")); + assert!(!is_truthy("")); + assert!(!is_truthy("anything-else")); + } + + #[test] + #[serial_test::serial] + fn parse_secs_handles_numeric_and_invalid() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_SECS", "42"); + assert_eq!(parse_secs("CLI_TEST_SECS"), Some(42)); + env.set("CLI_TEST_SECS", "not-a-number"); + assert_eq!(parse_secs("CLI_TEST_SECS"), None); + assert_eq!(parse_secs("CLI_TEST_NEVER_SET"), None); + } + + #[test] + #[serial_test::serial] + fn first_env_picks_first_set_value_and_skips_empty() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_FIRST_A", ""); + env.set("CLI_TEST_FIRST_B", "winner"); + env.set("CLI_TEST_FIRST_C", "loser"); + let a = "CLI_TEST_FIRST_A".to_string(); + let b = "CLI_TEST_FIRST_B".to_string(); + let c = "CLI_TEST_FIRST_C".to_string(); + assert_eq!(first_env([&a, &b, &c]), Some("winner".to_string())); + } + + #[test] + #[serial_test::serial] + fn ca_bundle_env_invalid_path_returns_error() { + let mut env = EnvGuard::default(); + env.set("CLI_E2E_TEST_CA_BUNDLE", "/no/such/file.pem"); + let cfg = HttpConfig::new("cli-e2e-test").unwrap(); + let err = cfg.build_client().expect_err("missing path should error"); + let msg = err.to_string(); + assert!(msg.contains("/no/such/file.pem"), "error: {msg}"); + } + + #[test] + #[serial_test::serial] + fn ca_bundle_env_empty_file_returns_error() { + let mut env = EnvGuard::default(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + env.set("CLI_EMPTY_BUNDLE_TEST_CA_BUNDLE", tmp.path()); + let cfg = HttpConfig::new("cli-empty-bundle-test").unwrap(); + let err = cfg.build_client().expect_err("empty bundle should error"); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("no pem") || msg.contains("empty"), "error: {msg}"); + } + + #[test] + #[serial_test::serial] + fn is_first_emission_dedupes_by_binary_and_kind() { + // The emission tracker is a process-global LazyLock, so this test + // shares state with anything else that calls `is_first_emission`. + // Serializing keeps it deterministic; unique binary-name keys would + // be required if other tests called it. + assert!(is_first_emission("emit-dedupe-test", "marker-1")); + assert!(!is_first_emission("emit-dedupe-test", "marker-1")); + assert!(is_first_emission("emit-dedupe-test", "marker-2")); + assert!(is_first_emission("emit-dedupe-test-other", "marker-1")); + } + + #[test] + fn scoped_helper_concatenates_prefix_and_suffix() { + assert_eq!(scoped("MYAPI", "_CA_BUNDLE"), "MYAPI_CA_BUNDLE"); + assert_eq!(scoped("OTHER", "_INSECURE"), "OTHER_INSECURE"); + } + + // ----- resolve() — transport-neutral view --------------------------------- + + /// Minimal valid self-signed PEM. Used by both reqwest and rustls parsers + /// to verify the round-trip stays byte-identical after going through + /// [`HttpConfig::resolve`]. + const TEST_PEM: &str = "-----BEGIN CERTIFICATE-----\n\ +MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw\n\ +DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow\n\ +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d\n\ +7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B\n\ +5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr\n\ +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1\n\ +NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l\n\ +Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc\n\ +6MF9+Yw1Yy0t\n\ +-----END CERTIFICATE-----\n"; + + #[test] + #[serial_test::serial] + fn resolve_clean_env_yields_no_overrides() { + // CI runners (notably ubuntu-latest) set SSL_CERT_FILE globally; + // `resolve()` reads it as the CA-bundle fallback so we must clear + // it for the duration of this test to actually see "clean env". + let _g = isolated_env_guard(); + let cfg = HttpConfig::new("resolve-clean").unwrap(); + let resolved = cfg.resolve().expect("clean env should resolve"); + assert!(resolved.extra_root_certs_pem.is_empty()); + assert!(!resolved.insecure_skip_verify); + assert!(resolved.proxy.is_none()); + assert!(resolved.connect_timeout.is_none()); + assert!(resolved.request_timeout.is_none()); + } + + #[test] + #[serial_test::serial] + fn resolve_preserves_compile_time_pem_bytes_unchanged() { + let _g = isolated_env_guard(); + let cfg = HttpConfig::new("resolve-ct-pem") + .unwrap() + .with_extra_root_cert(TEST_PEM.as_bytes()) + .expect("test PEM should parse"); + let resolved = cfg.resolve().expect("resolve should succeed"); + assert_eq!(resolved.extra_root_certs_pem.len(), 1); + // Round-trip must be byte-identical — non-reqwest transports parse + // these bytes with their own PEM reader and need them verbatim. + assert_eq!(resolved.extra_root_certs_pem[0], TEST_PEM.as_bytes()); + } + + #[test] + #[serial_test::serial] + fn resolve_appends_env_ca_bundle_after_compile_time_roots() { + let mut env = isolated_env_guard(); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, TEST_PEM.as_bytes()).unwrap(); + env.set("RESOLVE_ENV_PEM_CA_BUNDLE", tmp.path()); + + let cfg = HttpConfig::new("resolve-env-pem") + .unwrap() + .with_extra_root_cert(TEST_PEM.as_bytes()) + .unwrap(); + let resolved = cfg.resolve().expect("resolve should succeed"); + assert_eq!(resolved.extra_root_certs_pem.len(), 2, + "compile-time PEM first, env PEM appended"); + } + + #[test] + #[serial_test::serial] + fn resolve_invalid_ca_bundle_path_errors() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_BAD_PATH_CA_BUNDLE", "/no/such/file.pem"); + let cfg = HttpConfig::new("resolve-bad-path").unwrap(); + let err = cfg.resolve().expect_err("missing path should error"); + assert!(err.to_string().contains("/no/such/file.pem")); + } + + #[test] + #[serial_test::serial] + fn resolve_invalid_ca_bundle_contents_errors() { + let mut env = EnvGuard::default(); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, b"not a pem").unwrap(); + env.set("RESOLVE_BAD_PEM_CA_BUNDLE", tmp.path()); + let cfg = HttpConfig::new("resolve-bad-pem").unwrap(); + let err = cfg.resolve().expect_err("unparseable PEM should error"); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("ca bundle") || msg.contains("pem")); + } + + #[test] + #[serial_test::serial] + fn resolve_picks_up_insecure_flag() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_INSECURE_INSECURE", "1"); + let cfg = HttpConfig::new("resolve-insecure").unwrap(); + let resolved = cfg.resolve().unwrap(); + assert!(resolved.insecure_skip_verify); + } + + #[test] + #[serial_test::serial] + fn resolve_proxy_with_explicit_no_proxy_wins_over_env() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_PROXY_PROXY", "http://proxy.example:3128"); + env.set("RESOLVE_PROXY_NO_PROXY", "internal.example"); + env.set("NO_PROXY", "should-be-ignored"); + let cfg = HttpConfig::new("resolve-proxy").unwrap(); + let resolved = cfg.resolve().unwrap(); + let p = resolved.proxy.expect("proxy should be set"); + assert_eq!(p.url, "http://proxy.example:3128"); + assert_eq!(p.no_proxy.as_deref(), Some("internal.example")); + } + + #[test] + #[serial_test::serial] + fn resolve_proxy_falls_back_to_global_no_proxy() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_PROXY_FALLBACK_PROXY", "http://p.example:3128"); + env.set("NO_PROXY", "fallback.example"); + let cfg = HttpConfig::new("resolve-proxy-fallback").unwrap(); + let resolved = cfg.resolve().unwrap(); + let p = resolved.proxy.expect("proxy should be set"); + assert_eq!(p.no_proxy.as_deref(), Some("fallback.example")); + } + + #[test] + #[serial_test::serial] + fn resolve_and_build_client_agree_on_common_env_var_shape() { + // Cheap drift check: with the same env vars set, both readers + // succeed. This doesn't prove they map values identically into + // their respective output types (reqwest::Client vs + // ResolvedTlsConfig) — that would require introspecting reqwest + // internals — but it does catch the class of bug where one + // reader accepts an env-var combination the other rejects. + let mut env = isolated_env_guard(); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, TEST_PEM.as_bytes()).unwrap(); + env.set("RESOLVE_AGREE_CA_BUNDLE", tmp.path()); + env.set("RESOLVE_AGREE_TIMEOUT_SECS", "42"); + env.set("RESOLVE_AGREE_CONNECT_TIMEOUT_SECS", "7"); + + let cfg = HttpConfig::new("resolve-agree").unwrap(); + let resolved = cfg.resolve().expect("resolve should succeed"); + assert_eq!(resolved.extra_root_certs_pem.len(), 1); + assert_eq!(resolved.request_timeout, Some(Duration::from_secs(42))); + assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(7))); + + // build_client reads env vars independently. If it errors here + // with the same env set, the two readers have drifted on a + // value the spec says both accept. + cfg.build_client() + .expect("build_client should accept the same env vars as resolve()"); + } + + #[test] + #[serial_test::serial] + fn resolve_timeouts_parsed_as_seconds() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_TIMEOUTS_TIMEOUT_SECS", "30"); + env.set("RESOLVE_TIMEOUTS_CONNECT_TIMEOUT_SECS", "5"); + let cfg = HttpConfig::new("resolve-timeouts").unwrap(); + let resolved = cfg.resolve().unwrap(); + assert_eq!(resolved.request_timeout, Some(Duration::from_secs(30))); + assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(5))); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/lib.rs b/seed/cli/query-parameters-openapi/github-npm/src/lib.rs new file mode 100644 index 000000000000..304537e57f71 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/lib.rs @@ -0,0 +1,68 @@ +//! Fern CLI SDK +//! +//! A library for building CLIs from OpenAPI or GraphQL SDL schemas. +//! Uses `x-fern-sdk-group-name` and `x-fern-sdk-method-name` extensions +//! to build the command hierarchy. + +// Public API — building blocks +pub mod app; +pub mod arg_source; +pub mod auth; +pub mod binding; +pub mod cli_args; +pub mod completions; +pub(crate) mod custom_commands; +pub mod http; +pub mod error; +pub mod formatter; +pub mod graphql; +pub mod hooks; +pub mod man; +pub mod openapi; +pub mod stability; +pub mod validate; +pub mod websocket; + +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; + +// Internal modules +pub(crate) mod early_intercept; +pub(crate) mod logging; +pub(crate) mod output; +pub(crate) mod text; + +/// Initialize logging from environment variables. Call once at startup. +/// +/// `cli_name` is the binary name (e.g. `"my-cli"`). The function reads +/// `_LOG` and `_LOG_FILE` where `` is +/// `cli_name` uppercased with hyphens replaced by underscores. +pub fn init_logging(cli_name: &str) { + logging::init_logging(cli_name); +} + +/// Reset the `SIGPIPE` signal handler to its default disposition (`SIG_DFL`). +/// +/// Rust's runtime sets `SIGPIPE` to `SIG_IGN`, which causes writes to a +/// broken pipe (e.g. ` completion bash | head -5`) to return +/// `EPIPE` errors instead of terminating the process. For CLI tools that +/// produce large output this surfaces as a panic in `println!` or +/// `write_all`. Resetting to `SIG_DFL` lets the OS deliver the signal +/// and terminate the process cleanly — the standard behavior expected by +/// Unix pipelines. +/// +/// This is the idiomatic fix used by `bat`, `ripgrep`, `fd`, `eza`, and +/// most other Rust CLI tools. Called at the very top of each binary's +/// `run()` method before any I/O. +/// +/// On non-Unix platforms this is a no-op. +#[cfg(unix)] +pub fn reset_sigpipe() { + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_DFL); + } +} + +/// No-op on non-Unix platforms. +#[cfg(not(unix))] +pub fn reset_sigpipe() {} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/logging.rs b/seed/cli/query-parameters-openapi/github-npm/src/logging.rs new file mode 100644 index 000000000000..d90f70af5d4d --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/logging.rs @@ -0,0 +1,123 @@ +//! Structured Logging +//! +//! Provides opt-in, PII-free logging for HTTP requests and CLI operations. +//! All output goes to stderr or a log file — stdout remains clean for +//! machine-consumable JSON output. +//! +//! ## Environment Variables +//! +//! - `_LOG`: Filter directive for stderr logging +//! (e.g., `fern=debug`). `` is the CLI binary name uppercased +//! with hyphens replaced by underscores. If unset, no stderr logging. +//! +//! - `_LOG_FILE`: Directory path for JSON-line log +//! files with daily rotation. If unset, no file logging. + +use tracing_subscriber::prelude::*; + +/// Compute the env-var prefix from a CLI binary name: uppercase, hyphens → underscores. +fn env_prefix(cli_name: &str) -> String { + cli_name.to_uppercase().replace('-', "_") +} + +/// Initialize the tracing subscriber based on environment variables. +/// +/// `cli_name` is the binary name (e.g. `"my-cli"`). The function reads +/// `_LOG` and `_LOG_FILE` where `` is +/// `cli_name` uppercased with hyphens replaced by underscores. +/// +/// If neither variable is set, this is a no-op and logging adds zero +/// overhead. +pub fn init_logging(cli_name: &str) { + let prefix = env_prefix(cli_name); + let env_log = format!("{prefix}_LOG"); + let env_log_file = format!("{prefix}_LOG_FILE"); + + let stderr_filter = std::env::var(&env_log).ok(); + let log_file_dir = std::env::var(&env_log_file).ok(); + + if stderr_filter.is_none() && log_file_dir.is_none() { + return; + } + + let registry = tracing_subscriber::registry(); + + let stderr_layer = stderr_filter.map(|filter| { + let env_filter = tracing_subscriber::EnvFilter::new(filter); + tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_target(false) + .compact() + .with_filter(env_filter) + }); + + let (file_layer, _guard) = if let Some(ref dir) = log_file_dir { + let log_filename = format!("{cli_name}.log"); + let file_appender = tracing_appender::rolling::daily(dir, log_filename); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + let layer = tracing_subscriber::fmt::layer() + .json() + .with_writer(non_blocking) + .with_target(true) + .with_filter(tracing_subscriber::EnvFilter::new("debug")); + (Some(layer), Some(guard)) + } else { + (None, None) + }; + + let subscriber = registry.with(stderr_layer).with(file_layer); + if tracing::subscriber::set_global_default(subscriber).is_ok() { + if let Some(guard) = _guard { + std::mem::forget(guard); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn test_env_prefix() { + assert_eq!(env_prefix("test-cli"), "TEST_CLI"); + assert_eq!(env_prefix("box"), "BOX"); + assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); + } + + #[test] + fn test_env_var_names_derived() { + let prefix = env_prefix("test-cli"); + assert_eq!(format!("{prefix}_LOG"), "TEST_CLI_LOG"); + assert_eq!(format!("{prefix}_LOG_FILE"), "TEST_CLI_LOG_FILE"); + } + + #[test] + #[serial] + fn test_init_logging_default_no_panic() { + std::env::remove_var("TEST_CLI_LOG"); + std::env::remove_var("TEST_CLI_LOG_FILE"); + init_logging("test-cli"); + } + + #[test] + #[serial] + fn test_init_logging_with_stderr_filter_no_panic() { + // set_global_default may fail if another test already set it — that's fine, + // we still exercise the branch up to and including that call. + std::env::set_var("TEST_CLI_LOG", "fern=debug"); + std::env::remove_var("TEST_CLI_LOG_FILE"); + init_logging("test-cli"); + std::env::remove_var("TEST_CLI_LOG"); + } + + #[test] + #[serial] + fn test_init_logging_with_file_dir_no_panic() { + let dir = tempfile::tempdir().unwrap(); + std::env::remove_var("TEST_CLI_LOG"); + std::env::set_var("TEST_CLI_LOG_FILE", dir.path().to_str().unwrap()); + init_logging("test-cli"); + std::env::remove_var("TEST_CLI_LOG_FILE"); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/man.rs b/seed/cli/query-parameters-openapi/github-npm/src/man.rs new file mode 100644 index 000000000000..9bd15fd580c9 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/man.rs @@ -0,0 +1,101 @@ +//! Man page generation. +//! +//! Shared infrastructure for emitting roff-formatted man pages. Sits above +//! both protocol paths (`openapi/` and `graphql/`) and has no +//! protocol-specific dependencies. Mirrors the shape of `completions.rs`. + +use clap::Command; + +/// Returns `true` when `args` contains `"man"` as the first positional +/// token (i.e. the subcommand position). This allows early interception +/// before normal API dispatch — avoiding collision with an API resource +/// that might also be named `man`. +/// +/// Delegates to the shared [`crate::early_intercept::first_positional_is`] +/// helper which handles `--flag value` skip logic and boolean-flag awareness. +pub fn wants_man(args: &[String]) -> bool { + crate::early_intercept::first_positional_is(args, "man") +} + +/// Generate a roff-formatted man page for `cmd` and write it to `writer`. +/// +/// `bin_name` is the name the user types to invoke the CLI (e.g. `"box"`). +/// The caller is responsible for building a `Command` that mirrors the full +/// CLI surface (subcommands, flags, etc.) so the generated page is complete. +/// +/// Returns an IO error if writing fails. +pub fn generate_man_to(cmd: Command, bin_name: &str, writer: &mut dyn std::io::Write) -> std::io::Result<()> { + let cmd = cmd.name(bin_name.to_owned()); + let man = clap_mangen::Man::new(cmd); + let mut buf = Vec::new(); + man.render(&mut buf)?; + writer.write_all(&buf) +} + +/// Generate a roff-formatted man page for `cmd` and write it to stdout. +/// +/// Thin wrapper around [`generate_man_to`] that targets `stdout`. +pub fn generate_man(cmd: Command, bin_name: &str) -> std::io::Result<()> { + generate_man_to(cmd, bin_name, &mut std::io::stdout()) +} + +/// Build the `man` subcommand definition. Registered at the root of the +/// command tree so ` man` works. +pub fn man_command() -> Command { + Command::new("man") + .about("Generate a man page (roff format)") + .after_help( + "EXAMPLES:\n \ + # macOS / Linux (user-local)\n \ + man > ~/.local/share/man/man1/.1\n \ + # System-wide (Linux)\n \ + man | sudo tee /usr/local/share/man/man1/.1\n \ + # View directly without installing\n \ + man | groff -Tutf8 -man | less", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn wants_man_basic() { + assert!(wants_man(&args(&["box", "man"]))); + } + + #[test] + fn wants_man_false_when_flag_value() { + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); + } + + #[test] + fn wants_man_with_boolean_flag() { + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); + } + + #[test] + fn generate_man_produces_roff() { + let cmd = Command::new("box").about("test"); + let mut buf = Vec::new(); + generate_man_to(cmd, "box", &mut buf).expect("generate_man_to should succeed"); + let output = String::from_utf8(buf).expect("man page should be valid UTF-8"); + assert!( + output.contains(".TH"), + "man page should contain a .TH title-header macro, got:\n{}", + &output[..output.len().min(200)] + ); + assert!( + output.contains("box"), + "man page should contain the binary name" + ); + assert!( + output.contains("test"), + "man page should contain the about text" + ); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/app.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/app.rs new file mode 100644 index 000000000000..e0dcfb9e7deb --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/app.rs @@ -0,0 +1,3656 @@ +//! High-level API for building CLIs from OpenAPI specs. +//! +//! [`CliApp`] provides a builder-style API that lets consumers create a +//! fully-functional CLI in just a few lines. [`AppContext`] exposes the +//! loaded spec and executor so that custom command handlers can call the +//! API programmatically. + +use std::collections::HashMap; + +use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; +use crate::error::CliError; +use crate::formatter; +use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; +use crate::openapi::executor; + +/// Split a slash-delimited prefix string into its path components, dropping +/// empty segments so accidental leading/trailing slashes are forgiving. +fn split_prefix(prefix: &str) -> Vec { + prefix + .split('/') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// Merge `incoming` resources into `target` at the given nested namespace +/// path. Empty path = flat top-level merge. Multi-segment path = walk/create +/// intermediate resources, merge at the leaf. +/// +/// **Stutter elision:** at the leaf, if `incoming` contains a top-level +/// resource whose name matches the leaf namespace, that resource's methods +/// and sub-resources are *hoisted* into the namespace itself — eliminating +/// the `myapi v3 customers customers get` repetition that would +/// otherwise occur when a spec's primary domain matches the namespace name. +/// Other top-level resources from the spec become children of the +/// namespace as usual. +fn merge_into_path( + target: &mut HashMap, + path: &[String], + mut incoming: HashMap, +) -> Result<(), CliError> { + if path.is_empty() { + for key in incoming.keys() { + if target.contains_key(key) { + return Err(CliError::Discovery(format!( + "Resource key collision: '{key}' appears in multiple specs" + ))); + } + } + target.extend(incoming); + return Ok(()); + } + + if path.len() == 1 { + let leaf = path[0].clone(); + let entry = target.entry(leaf.clone()).or_insert_with(|| RestResource { + resources: HashMap::new(), + methods: HashMap::new(), + }); + + // Hoist a matching-name resource from the spec into the namespace. + if let Some(matching) = incoming.remove(&leaf) { + for (k, v) in matching.methods { + if entry.methods.contains_key(&k) { + return Err(CliError::Discovery(format!( + "Method key collision: '{k}' under namespace '{leaf}'" + ))); + } + entry.methods.insert(k, v); + } + for (k, v) in matching.resources { + if entry.resources.contains_key(&k) { + return Err(CliError::Discovery(format!( + "Resource key collision: '{k}' under namespace '{leaf}'" + ))); + } + entry.resources.insert(k, v); + } + } + + for (k, v) in incoming { + if entry.resources.contains_key(&k) { + return Err(CliError::Discovery(format!( + "Resource key collision: '{k}' under namespace '{leaf}'" + ))); + } + entry.resources.insert(k, v); + } + return Ok(()); + } + + let head = path[0].clone(); + let entry = target.entry(head).or_insert_with(|| RestResource { + resources: HashMap::new(), + methods: HashMap::new(), + }); + merge_into_path(&mut entry.resources, &path[1..], incoming) +} + +/// Replace `{name}` substrings in `s` with values from `subs`. Variables not +/// in the map are left literal so dry-run output and downstream errors can +/// still pinpoint what's missing. +fn substitute_url_vars(s: &str, subs: &HashMap) -> String { + let mut out = s.to_string(); + for (name, value) in subs { + out = out.replace(&format!("{{{name}}}"), value); + } + out +} + +/// Walk the merged doc and substitute server variables in every `root_url` +/// (top-level + per-method, since per-operation server overrides each have +/// their own URL). +fn apply_server_var_substitutions( + doc: &mut crate::openapi::discovery::RestDescription, + subs: &HashMap, +) { + if subs.is_empty() { + return; + } + doc.root_url = substitute_url_vars(&doc.root_url, subs); + for server in &mut doc.servers { + server.url = substitute_url_vars(&server.url, subs); + } + fn walk(res: &mut crate::openapi::discovery::RestResource, subs: &HashMap) { + for method in res.methods.values_mut() { + method.root_url = substitute_url_vars(&method.root_url, subs); + for server in &mut method.servers { + server.url = substitute_url_vars(&server.url, subs); + } + } + for sub in res.resources.values_mut() { + walk(sub, subs); + } + } + for res in doc.resources.values_mut() { + walk(res, subs); + } +} + +/// Apply generator-supplied env-var overrides to every idempotent +/// operation's synthetic idempotency-header parameter. The parser +/// already populated `MethodParameter.env_var` from each +/// `IdempotencyHeader.env` declared in the spec; this pass overlays the +/// builder map so calls like `.idempotency_header_env("Idempotency-Key", +/// "API_IDEMPOTENCY_KEY")` win over a value baked into the spec. +/// +/// Keys in `envs` are matched against the entry's `name` first, then +/// its `header` value — letting generators register against whichever +/// identifier they emit at the call site. +fn apply_idempotency_header_envs( + doc: &mut crate::openapi::discovery::RestDescription, + envs: &HashMap, +) { + if envs.is_empty() || doc.idempotency_headers.is_empty() { + return; + } + + // Resolve each idempotency header's wire header name to an env var, + // checking the `name` field first and falling back to `header`. + // Collected up front so the per-method walk below is O(headers) per + // method instead of O(headers * builder_entries). + let mut header_to_env: HashMap = HashMap::new(); + for h in &doc.idempotency_headers { + let resolved = h + .name + .as_deref() + .and_then(|n| envs.get(n)) + .or_else(|| envs.get(&h.header)); + if let Some(env_var) = resolved { + header_to_env.insert(h.header.clone(), env_var.clone()); + } + } + if header_to_env.is_empty() { + return; + } + + fn walk( + res: &mut crate::openapi::discovery::RestResource, + header_to_env: &HashMap, + ) { + for method in res.methods.values_mut() { + if !method.idempotent { + continue; + } + for (header, env_var) in header_to_env { + if let Some(param) = method.parameters.get_mut(header) { + if param.location.as_deref() == Some("header") { + param.env_var = Some(env_var.clone()); + } + } + } + } + for sub in res.resources.values_mut() { + walk(sub, header_to_env); + } + } + for res in doc.resources.values_mut() { + walk(res, &header_to_env); + } +} + +fn merge_schemas( + acc: &mut HashMap, + incoming: HashMap, +) -> Result<(), CliError> { + // Multi-spec setups share common schema + // names (`ErrorResponse`, `Pagination`, `Meta`) across many specs that are + // authored from the same template — collisions are the norm, not a bug. + // First write wins; schemas are only used for best-effort request-body + // validation, so a worst-case mismatch surfaces as a client-side + // validation warning, not silent corruption. A future structural-equality + // check could promote real differences back to an error. + for (key, schema) in incoming { + acc.entry(key).or_insert(schema); + } + Ok(()) +} + +/// Merge security-scheme declarations from another spec into the accumulator. +/// First write wins on collisions — multi-spec setups frequently re-declare a +/// shared `bearerAuth` from a common template, and a structural-equality check +/// would surface noise rather than help. Each operation's +/// `security_requirements` are denormalized into the operation itself at parse +/// time, so schemes only need to be merged at the top level for the eventual +/// `RoutingAuthProvider` registry. +fn merge_security_schemes( + acc: &mut HashMap, + incoming: HashMap, +) { + for (key, scheme) in incoming { + acc.entry(key).or_insert(scheme); + } +} + +/// Merge `x-fern-sdk-variables` declarations across specs. First write +/// wins on name collisions, mirroring [`merge_schemas`] and +/// [`merge_security_schemes`]. Multi-spec setups that share a common +/// variable across two OpenAPI files should only register the flag once +/// at the root, and a single source of truth is what makes resolution +/// deterministic. +fn merge_sdk_variables( + acc: &mut Vec, + incoming: Vec, +) { + use std::collections::HashSet; + let existing: HashSet = acc.iter().map(|v| v.name.clone()).collect(); + for var in incoming { + if !existing.contains(&var.name) { + acc.push(var); + } + } +} + +/// Returns true when the kebab-cased flag derived from an +/// `x-fern-sdk-variables` declaration collides with a built-in CLI flag +/// (`--params`, `--format`, `--dry-run`, …). Registering a global with +/// the same long name would panic clap's debug_assert at command tree +/// construction; the caller skips the offending entry and emits a +/// `tracing::warn!` so the spec author can rename the variable. +pub(crate) fn sdk_variable_collides_with_builtin(kebab: &str) -> bool { + crate::openapi::commands::BUILTIN_FLAG_NAMES.contains(&kebab) +} + +/// Merge `x-fern-global-headers` declarations across specs. First write +/// wins on header-name collisions, mirroring [`merge_sdk_variables`]. +/// Multi-spec setups that share a common header across two OpenAPI files +/// should only register the flag once at the root. +fn merge_global_headers( + acc: &mut Vec, + incoming: Vec, +) { + use std::collections::HashSet; + let existing: HashSet = acc.iter().map(|h| h.header.clone()).collect(); + for h in incoming { + if !existing.contains(&h.header) { + acc.push(h); + } + } +} + +/// Derive the kebab-cased CLI flag (`--`) for a global header. +/// Prefers `name` (the SDK display identifier) when present; otherwise +/// falls back to kebab-casing the wire header name. Mirrors the +/// `flag_name_override` pathway used by `x-fern-idempotency-headers`. +pub(crate) fn global_header_flag_name(h: &crate::openapi::discovery::GlobalHeader) -> String { + let source = h.name.as_deref().unwrap_or(h.header.as_str()); + crate::text::to_kebab_flag(source) +} + +/// Stable clap arg ID for a global header. Anchored to the wire header +/// name so per-op parameter lookups (which key off the same string) +/// remain consistent with what clap returns. +pub(crate) fn global_header_arg_id(h: &crate::openapi::discovery::GlobalHeader) -> String { + format!("__global_header::{}", h.header) +} + +/// Returns true when the kebab-cased flag derived from an +/// `x-fern-global-headers` entry collides with a built-in CLI flag +/// (`--params`, `--format`, …) or an already-registered global. clap +/// would panic in debug builds on collision; we skip the offending entry +/// with a `tracing::warn!` so the spec still loads. +fn global_header_flag_collides_with_builtin(kebab: &str) -> bool { + crate::openapi::commands::BUILTIN_FLAG_NAMES.contains(&kebab) +} + +/// Resolve a global header value from `matched_args`, the env, and the +/// configured default — in that order. Returns `None` when none of the +/// three sources produced a value, OR when the resolved value is empty +/// or whitespace-only (callers shouldn't stamp a header like `X-API-Stage:` +/// on the wire — that's almost always a user mistake worth surfacing as a +/// required-header error, and matches the env-var-handling convention). +/// +/// `matched_args.get_one::` already incorporates clap's +/// `.env()` and `.default_value()` bindings, so the lookup is a single +/// read; the explicit env/default fields on [`GlobalHeader`] are what +/// feed those clap bindings at registration time. +pub(crate) fn resolve_global_header_value( + matched_args: &clap::ArgMatches, + h: &crate::openapi::discovery::GlobalHeader, +) -> Option { + matched_args + .get_one::(&global_header_arg_id(h)) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + +/// True when an operation declares a `header`-located parameter with +/// the same wire-name as a global header AND the user supplied a value +/// for it in `params`. HTTP header names are case-insensitive per RFC +/// 7230 §3.2, so the lookup is `eq_ignore_ascii_case` rather than +/// `HashMap::contains_key` / `HashMap::get`. +pub(crate) fn per_op_header_param_overrides_global( + params: &serde_json::Map, + method: &RestMethod, + wire_name: &str, +) -> bool { + let supplied = params + .keys() + .any(|k| k.eq_ignore_ascii_case(wire_name)); + if !supplied { + return false; + } + method + .parameters + .iter() + .any(|(k, p)| k.eq_ignore_ascii_case(wire_name) && p.location.as_deref() == Some("header")) +} + +/// Build the structured validation error used when a required global +/// header has neither a CLI/env/default value nor a per-op override. +/// Shared by both the built-in command path +/// ([`build_global_header_overrides`]) and the custom-command path +/// ([`AppContext::extra_headers_for`]) so users get the same message +/// regardless of which dispatcher they hit. +fn missing_required_global_header_error(h: &crate::openapi::discovery::GlobalHeader) -> CliError { + let flag = global_header_flag_name(h); + let env_hint = match &h.env { + Some(e) => format!(" or set ${e}"), + None => String::new(), + }; + CliError::Validation(format!( + "Missing required global header '{}': provide --{}{}", + h.header, flag, env_hint + )) +} + +/// Shared implementation of the per-op-aware required-header walk. +/// Both [`build_global_header_overrides`] (built-in path) and +/// [`AppContext::extra_headers_for`] (custom-command path) call this +/// helper, differing only in how a header's value is resolved — the +/// built-in path reads directly from clap's `ArgMatches`, the +/// custom-command path looks up the pre-resolved map. +/// +/// Walks `doc_global_headers` and for each entry: +/// * skips if the operation declares a same-named header param that +/// the user supplied (per-op wins); +/// * emits `(wire-name, value)` if the resolver returns a non-empty +/// value; +/// * errors if the header is required (`optional: false`) and neither +/// a resolved value nor a per-op override is present. +/// +/// The resolver closure is responsible for any trimming / empty-string +/// filtering — see [`resolve_global_header_value`] for the canonical +/// implementation. +fn finalize_global_header_overrides( + doc_global_headers: &[crate::openapi::discovery::GlobalHeader], + method: &RestMethod, + per_op_params: &serde_json::Map, + mut resolver: R, +) -> Result, CliError> +where + R: FnMut(&crate::openapi::discovery::GlobalHeader) -> Option, +{ + let mut out = Vec::new(); + for h in doc_global_headers { + let overridden_by_per_op = + per_op_header_param_overrides_global(per_op_params, method, &h.header); + let resolved = resolver(h); + match (resolved, overridden_by_per_op) { + (Some(value), false) => out.push((h.header.clone(), value)), + (Some(_), true) => { /* per-op wins, do not stamp */ } + (None, true) => { /* per-op satisfies the required check */ } + (None, false) => { + if !h.optional { + return Err(missing_required_global_header_error(h)); + } + } + } + } + Ok(out) +} + +/// Build the resolved `(wire-name, value)` list of `x-fern-global-headers` +/// to stamp on every outgoing request for this invocation. +/// +/// The resolution chain per header is `CLI flag > env var > default`, +/// implemented by clap's `.env()` + `.default_value()` bindings — see +/// the registration loop in `run_async`. +/// +/// Per-operation overrides: if the operation declares a `header`-located +/// parameter with the same (case-insensitive) wire-name AND the user +/// supplied a value for it (present in `params`), the global header is +/// suppressed; the per-op value wins both on the wire and in the +/// required-header satisfiability check. This mirrors Fern's importer +/// behavior where a header parameter declared on the operation replaces +/// the global. +/// +/// Errors when a `required` (i.e. `optional: false`) global header has +/// neither a CLI/env/default value nor a per-op override. +pub(crate) fn build_global_header_overrides( + matched_args: &clap::ArgMatches, + doc: &RestDescription, + method: &RestMethod, + params: &serde_json::Map, +) -> Result, CliError> { + finalize_global_header_overrides(&doc.global_headers, method, params, |h| { + resolve_global_header_value(matched_args, h) + }) +} + +/// Compose the root `--help` footer from the optional global-headers +/// section, the optional auth section, and the always-present runtime +/// footer. Sections are joined with a single newline; absent sections +/// are skipped entirely (no stray blank dividers). +/// +/// Extracted so the section-skipping logic is unit-testable in +/// isolation — the clap `Command` it eventually feeds into is opaque +/// and harder to introspect from tests. +pub(crate) fn compose_root_after_help_sections( + global_headers_section: Option<&str>, + auth_section: Option<&str>, + footer: &str, +) -> String { + let mut sections: Vec<&str> = Vec::with_capacity(3); + if let Some(s) = global_headers_section { + sections.push(s); + } + if let Some(s) = auth_section { + sections.push(s); + } + sections.push(footer); + sections.join("\n") +} + +/// Internal entry describing one OpenAPI spec to be merged. +pub(crate) struct SpecEntry { + yaml: String, + /// Empty = flat at the top level. One entry = wrap under that prefix. + /// Multiple = wrap under nested resources (`["v3", "customers"]` → + /// `v3.customers.*`). Path is constructed from slash-delimited input on + /// the public API. + prefix_path: Vec, + /// Overlay documents to apply before parsing. + overlays: Vec, + /// Optional overrides YAML strings that are deep-merged onto the base spec + /// before parsing. Applied sequentially — later overrides take precedence. + /// Matches the Fern CLI `generators.yml` `overrides:` key behavior: + /// maps merge key-by-key, arrays replace wholesale, `null` deletes keys. + overrides: Vec, +} + +/// A server-URL template variable like `{store_hash}` in +/// `https://api.example.com/stores/{store_hash}/v3`. Resolved at runtime +/// from a CLI flag (`--`), an env var, or a built-in default — first +/// match wins. +#[derive(Clone)] +pub(crate) struct ServerVar { + /// OpenAPI variable name as it appears in the URL template (`store_hash`). + name: String, + /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). + env_var: Option, + /// Fallback default (for variables that have one — most + /// store identifiers don't). + default: Option, + /// One-line `--help` string. + description: Option, +} + +/// Builder for a schema-driven CLI application (OpenAPI). +pub struct CliApp { + pub(crate) name: String, + pub(crate) specs: Vec, + title_override: Option, + description_override: Option, + /// Auth bindings registered via [`auth_scheme`](Self::auth_scheme), + /// [`auth_basic_scheme`](Self::auth_basic_scheme), and + /// [`auth_provider`](Self::auth_provider). The constructed provider is + /// built from these (lowered against the spec's + /// `components.securitySchemes`). + pub(crate) auth_bindings: Vec<(String, SchemeBinding)>, + /// Override for how bindings compose. Defaults to [`AuthStrategy::Auto`] + /// — the spec drives the choice. Generators that already know the + /// API's auth model can pin a specific strategy. + auth_strategy: AuthStrategy, + /// Trust roots parsed at builder-call time. Storing parsed certs (not + /// raw bytes) means the validation error message lives in one place + /// — at the call site of `extra_root_cert`, where it's most useful. + pub(crate) extra_root_certs: Vec, + /// Raw PEM bytes for each trust root added via `extra_root_cert`, kept + /// alongside the parsed `extra_root_certs` above. Threaded through to + /// `HttpConfig::with_parsed_root_certs` so transport-neutral callers + /// (`HttpConfig::resolve`) can hand PEM to non-reqwest TLS connectors + /// (e.g. `tokio-tungstenite`). + pub(crate) extra_root_certs_pem: Vec>, + pub(crate) server_vars: Vec, + /// Generator-supplied environment-variable overrides for spec-root + /// idempotency headers (parsed from `x-fern-idempotency-headers`). + /// Keyed by the entry's `name` (preferred) or `header` value; + /// `CliApp::build_doc` applies these to every idempotent operation's + /// synthetic header parameter so the `--` accepts the value + /// from the env var as a fallback. + idempotency_header_envs: HashMap, + /// Compile-time preset audiences. Operations whose + /// `x-fern-audiences` doesn't intersect this set are dropped from + /// the command tree before clap ever sees them. Empty (the default) + /// = no filter — every operation is included. + /// + /// Configured by the binary's `main.rs` via [`Self::audiences`]; not + /// exposed as a CLI flag, mirroring fern's intent that audience + /// selection is a build-time decision baked into the generated SDK + /// (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). + pub(crate) audiences: Vec, +} + +#[allow(dead_code)] // Methods available for binding wrappers to delegate to. +impl CliApp { + /// Create a new CLI application with the given binary name. + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + specs: Vec::new(), + title_override: None, + description_override: None, + auth_bindings: Vec::new(), + auth_strategy: AuthStrategy::Auto, + extra_root_certs: Vec::new(), + extra_root_certs_pem: Vec::new(), + server_vars: Vec::new(), + idempotency_header_envs: HashMap::new(), + audiences: Vec::new(), + } + } + + /// Pin the CLI surface to operations tagged with one of the given + /// `x-fern-audiences` values. Operations without an + /// `x-fern-audiences` tag, or whose tags don't intersect this set, + /// are dropped from the command tree at build time — they don't + /// appear in `--help`, JSON help, completions, or anywhere else. + /// + /// Multiple audiences union (OR): an operation tagged with *any* of + /// the listed audiences survives. Calling `.audiences([])` (or not + /// calling this at all) is a no-op — every operation is included. + /// + /// Audience selection is a compile-time decision baked into each + /// binary's `main.rs`, not a runtime flag. This mirrors fern's + /// importer semantics + /// (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`), + /// where the audience filter physically removes operations from the + /// IR rather than hiding them at execution time. + /// + /// ```ignore + /// CliApp::new("my-public-api") + /// .spec(include_str!("openapi.yaml")) + /// .audiences(["public"]) + /// .run(); + /// ``` + pub fn audiences(mut self, audiences: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.audiences = audiences + .into_iter() + .map(Into::into) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + self + } + + /// Register an environment-variable fallback for a spec-root + /// idempotency header (declared via `x-fern-idempotency-headers`). + /// + /// `name` matches against the entry's `name` field first, then its + /// `header` field — whichever the generator finds most convenient at + /// the call site. When the user invokes an idempotent operation + /// without the corresponding `--`, the value is taken from the + /// named environment variable. + /// + /// ```ignore + /// CliApp::new("api") + /// .spec(include_str!("openapi.yaml")) + /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") + /// .run(); + /// ``` + /// + /// This is the cli-sdk entry point referenced by FER-9852, where the + /// generator emits one call per parsed idempotency header. The + /// header itself is only sent on operations marked + /// `x-fern-idempotent: true`; non-idempotent operations are + /// unaffected. + pub fn idempotency_header_env(mut self, name: &str, env_var: &str) -> Self { + self.idempotency_header_envs.insert(name.to_string(), env_var.to_string()); + self + } + + /// Register a server-URL template variable (e.g. `{store_hash}`). + /// + /// Auto-generates a global `--` flag (with kebab-cased name) and + /// resolves the value at request time from, in order: + /// 1. The CLI flag + /// 2. The given env var (if any) + /// 3. The built-in default (if any) + /// 4. Otherwise, errors with a helpful message + /// + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. Variables + /// referenced in `servers[].url` but not registered here remain literal + /// in the URL (and the request will fail at send time), so registering + /// them is effectively required. + pub fn server_var( + mut self, + name: &str, + env_var: Option<&str>, + default: Option<&str>, + description: Option<&str>, + ) -> Self { + self.server_vars.push(ServerVar { + name: name.to_string(), + env_var: env_var.map(str::to_string), + default: default.map(str::to_string), + description: description.map(str::to_string), + }); + self + } + + /// Add an OpenAPI spec YAML string. May be called multiple times; specs are flat-merged. + /// Typically used with `include_str!`. + pub fn spec(mut self, yaml: &str) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: Vec::new(), + overlays: Vec::new(), + overrides: Vec::new(), + }); + self + } + + /// Add an OpenAPI spec with a Fern-style overrides file applied before parsing. + /// + /// The override YAML is deep-merged onto the spec: maps merge key-by-key + /// (override wins on leaf collisions), arrays replace wholesale, and + /// `null` values delete the corresponding key. This matches the Fern CLI's + /// `generators.yml` `overrides:` behavior. + /// + /// Use this to add `x-fern-sdk-group-name`, `x-fern-sdk-method-name`, or + /// any other spec-level patches without modifying the upstream spec. + pub fn spec_with_overrides(mut self, yaml: &str, overrides: &str) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: Vec::new(), + overlays: Vec::new(), + overrides: vec![overrides.to_string()], + }); + self + } + + /// Add an OpenAPI spec whose resources are wrapped under `prefix`. Use + /// slashes to nest: `"v3/customers"` puts the spec's resources under + /// `v3.customers.*`. Multiple `spec_under` calls with the same path + /// merge into a shared namespace; inner-resource collisions error. + pub fn spec_under(mut self, prefix: &str, yaml: &str) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: split_prefix(prefix), + overlays: Vec::new(), + overrides: Vec::new(), + }); + self + } + + /// Like [`spec_under`](Self::spec_under), but with a Fern-style overrides + /// file deep-merged onto the spec before parsing. + pub fn spec_under_with_overrides( + mut self, + prefix: &str, + yaml: &str, + overrides: &str, + ) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: split_prefix(prefix), + overlays: Vec::new(), + overrides: vec![overrides.to_string()], + }); + self + } + + /// Add multiple specs that all merge under the same `prefix` (flat). + /// Equivalent to repeated `spec_under` calls; inner-resource collisions + /// across the specs error at startup. + pub fn specs_under(mut self, prefix: &str, yamls: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let path = split_prefix(prefix); + for yaml in yamls { + self.specs.push(SpecEntry { + yaml: yaml.as_ref().to_string(), + prefix_path: path.clone(), + overlays: Vec::new(), + overrides: Vec::new(), + }); + } + self + } + + /// Add multiple specs under `prefix`, each given its own sub-namespace. + /// `specs_under_named("v3", [("customers", yaml1), ("orders", yaml2)])` + /// produces `v3.customers.*` and `v3.orders.*` — what `specs_under` + /// would flatten, this preserves per-spec scoping. Useful when specs + /// share cross-cutting tags (`Metafields`) that would otherwise collide + /// once flattened. + pub fn specs_under_named(mut self, prefix: &str, named: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + let parent = split_prefix(prefix); + for (sub, yaml) in named { + let mut path = parent.clone(); + path.extend(split_prefix(sub.as_ref())); + self.specs.push(SpecEntry { + yaml: yaml.as_ref().to_string(), + prefix_path: path, + overlays: Vec::new(), + overrides: Vec::new(), + }); + } + self + } + + /// Like [`specs_under_named`](Self::specs_under_named), but each entry is + /// a `(name, yaml, overrides_yaml)` triple. The overrides file is + /// deep-merged onto the spec before parsing. + /// + /// ```ignore + /// CliApp::new("myapi") + /// .specs_under_named_with_overrides("v3", [ + /// ("customers", + /// include_str!("specs/management/customers.v3.yml"), + /// include_str!("overrides/management/customers.v3.yml")), + /// ]) + /// ``` + pub fn specs_under_named_with_overrides( + mut self, + prefix: &str, + named: I, + ) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + O: AsRef, + { + let parent = split_prefix(prefix); + for (sub, yaml, overrides) in named { + let mut path = parent.clone(); + path.extend(split_prefix(sub.as_ref())); + self.specs.push(SpecEntry { + yaml: yaml.as_ref().to_string(), + prefix_path: path, + overlays: Vec::new(), + overrides: vec![overrides.as_ref().to_string()], + }); + } + self + } + + /// Add an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) + /// to the most recently added spec. Overlays are applied in order before + /// the spec is parsed into the internal representation. + /// + /// # Panics + /// + /// Panics if called before `.spec()` or `.spec_under()`. + /// + /// # Example + /// + /// ```rust,ignore + /// use fern_cli_sdk::openapi::CliApp; + /// + /// CliApp::new("my-api") + /// .spec(include_str!("openapi.yaml")) + /// .overlay(include_str!("overlay.yaml")) + /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") + /// .run() + /// ``` + pub fn overlay(mut self, overlay_yaml: &str) -> Self { + let entry = self + .specs + .last_mut() + .expect("overlay() called before spec(); add a spec first"); + entry.overlays.push(overlay_yaml.to_string()); + self + } + + /// Override the top-level --help title, regardless of what the spec(s) declare. + pub fn title(mut self, t: &str) -> Self { + self.title_override = Some(t.to_string()); + self + } + + /// Override the top-level --help description, regardless of what the spec(s) declare. + pub fn description(mut self, d: &str) -> Self { + self.description_override = Some(d.to_string()); + self + } + + /// Build the merged `RestDescription` from all registered specs. + pub(crate) fn build_doc(&self) -> Result { + if self.specs.is_empty() { + return Err(CliError::Discovery( + "No spec provided. Call .spec() on CliApp.".to_string(), + )); + } + + let mut merged: Option = None; + + for entry in &self.specs { + // 1. Apply overlays (RFC 7396 style) first. + let effective_yaml = crate::openapi::overlay::apply_overlays_to_spec( + &entry.yaml, + &entry.overlays, + )?; + + // 2. Apply Fern-style overrides (deep-merge) on top. + let spec_doc = if entry.overrides.is_empty() { + crate::openapi::load_openapi_spec(&effective_yaml, &self.name)? + } else { + let mut value: serde_yaml::Value = serde_yaml::from_str(&effective_yaml) + .map_err(|e| CliError::Discovery( + format!("Failed to parse OpenAPI spec: {e}"), + ))?; + for ovr in &entry.overrides { + let override_value: serde_yaml::Value = serde_yaml::from_str(ovr) + .map_err(|e| CliError::Discovery( + format!("Failed to parse overrides YAML: {e}"), + ))?; + value = crate::openapi::deep_merge_yaml(value, override_value); + } + crate::openapi::load_openapi_spec_from_value(value, &self.name)? + }; + + match merged { + None => { + let mut base = spec_doc; + let resources = std::mem::take(&mut base.resources); + base.resources = HashMap::new(); + merge_into_path(&mut base.resources, &entry.prefix_path, resources)?; + merged = Some(base); + } + Some(ref mut acc) => { + merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; + merge_schemas(&mut acc.schemas, spec_doc.schemas)?; + merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); + merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); + } + } + } + + let mut doc = merged.expect("at least one spec was processed"); + if let Some(ref t) = self.title_override { + doc.title = Some(t.clone()); + } + if let Some(ref d) = self.description_override { + doc.description = Some(d.clone()); + } + + // Apply generator-supplied idempotency-header env overrides. + // The parser populates each idempotent operation's synthetic + // header MethodParameter with `env_var = entry.env` from the + // spec; this pass lets the generator override or supply that + // mapping post-hoc (FER-9852 builder API) so end users don't + // need to edit the spec to wire a new env var. + if !self.idempotency_header_envs.is_empty() { + apply_idempotency_header_envs(&mut doc, &self.idempotency_header_envs); + } + + Ok(doc) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::from_env(env))`. + /// Covers the 80% case — most callers bind a scheme to one env var. + /// + /// ```ignore + /// CliApp::new("api") + /// .spec(include_str!("openapi.yaml")) + /// .auth_scheme_env("bearerAuth", "API_TOKEN") + /// .run(); + /// ``` + pub fn auth_scheme_env(self, scheme_name: &str, env_var: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::from_env(env_var)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::cli(arg_name))`. + /// Auto-registers a global `--` flag at run time. Accepts + /// either `"api-token"` or `"--api-token"`. + pub fn auth_scheme_cli(self, scheme_name: &str, arg_name: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::cli(arg_name)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::file(path))`. + /// `~` and `~/` are expanded against `$HOME`. + pub fn auth_scheme_file(self, scheme_name: &str, path: impl AsRef) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::file(path)) + } + + /// Bind a credential source to a single-value auth scheme declared in the + /// spec's `components.securitySchemes` (bearer / apiKey / oauth2). + /// + /// `scheme_name` should match the spec key. The credential's resolved + /// value is sent according to the scheme's declared shape: + /// + /// | Scheme | Outgoing | + /// | -------------------- | ------------------------------------- | + /// | `http: bearer` | `Authorization: Bearer ` | + /// | `apiKey, in: header` | `: ` | + /// | `oauth2` | `Authorization: Bearer ` | + /// + /// When any operation in the spec declares per-endpoint `security:`, + /// the constructed provider is a [`RoutingAuthProvider`][rap] that picks + /// the right scheme per request. Otherwise it's a plain + /// [`AnyAuthProvider`][aap] that tries each binding in order. + /// + /// [rap]: crate::auth::RoutingAuthProvider + /// [aap]: crate::auth::AnyAuthProvider + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.auth_bindings + .push((scheme_name.to_string(), SchemeBinding::Token(source))); + self + } + + /// Bind separate username and password sources to an `http: basic` scheme. + /// Both must resolve for the provider to attach `Authorization: Basic + /// base64(user:pass)`; if either is missing the binding contributes no + /// credentials. + pub fn auth_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Basic { username, password }, + )); + self + } + + /// Plug in a fully-custom [`AuthProvider`][crate::auth::AuthProvider] for + /// a scheme name. Useful when the spec uses a scheme the SDK doesn't + /// model out-of-the-box (mTLS-derived headers, request signing, OAuth2 + /// client-credentials with token refresh, etc.). + /// + /// Accepts any concrete `AuthProvider` by value and wraps it in [`Arc`] + /// internally. For pre-built `Arc` values (sharing a + /// provider across multiple binders), use [`auth_provider_shared`]. + /// + /// [`auth_provider_shared`]: Self::auth_provider_shared + pub fn auth_provider

(self, scheme_name: &str, provider: P) -> Self + where + P: crate::auth::AuthProvider + 'static, + { + self.auth_provider_shared(scheme_name, std::sync::Arc::new(provider)) + } + + /// Same as [`auth_provider`] but takes an already-built + /// [`DynAuthProvider`]. Use this when sharing one provider across + /// multiple bindings or storing custom providers in a registry. + /// + /// [`auth_provider`]: Self::auth_provider + pub fn auth_provider_shared( + mut self, + scheme_name: &str, + provider: DynAuthProvider, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Custom(provider), + )); + self + } + + /// Pin how the bound auth schemes compose into a single provider. + /// Defaults to [`AuthStrategy::Auto`], which derives the strategy from + /// the spec (Routing if any operation declares per-endpoint security, + /// otherwise Any). + /// + /// Generators that know their API's auth model statically can override + /// this — most importantly to express the [`All`][a] case (every + /// scheme on every request) which the spec doesn't always model. + /// + /// [a]: AuthStrategy::All + pub fn auth_strategy(mut self, strategy: AuthStrategy) -> Self { + self.auth_strategy = strategy; + self + } + + /// Register an extra trust root that this CLI will accept on top of the + /// system's default roots. `pem` must be a PEM-encoded certificate (or + /// concatenated PEM bundle), typically loaded with `include_bytes!`. + /// + /// Useful for distributing a CLI inside an organization where every + /// machine should trust the company's internal CA out of the box, without + /// asking each user to set `_CA_BUNDLE`. + /// + /// ```ignore + /// # // ignored: needs a real PEM file at the include path. + /// CliApp::new("internal-tool") + /// .spec(include_str!("openapi.yaml")) + /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) + /// .run() + /// ``` + /// + /// Panics if the bytes don't parse as PEM, or if the PEM contains no + /// certificates. Failing fast at startup is preferable to silently + /// shipping a CLI that ignores its bundled cert. + pub fn extra_root_cert(mut self, pem: &[u8]) -> Self { + // Share the validation path with `HttpConfig::with_extra_root_cert` + // so error wording stays in sync between the panicking builder API + // and the Result-returning lower-level API. + let certs = crate::http::parse_extra_root_cert(pem) + .unwrap_or_else(|e| panic!("CliApp::extra_root_cert: {e}")); + self.extra_root_certs.extend(certs); + self.extra_root_certs_pem.push(pem.to_vec()); + self + } + + /// Decorate a clap `Command` with server-variable flags, SDK-variable + /// flags, global-header flags, and the composed help footer. + /// Called from `OpenApiBinding::build_command()` to replicate what the + /// old `run_async` pipeline used to do inline. + pub(crate) fn decorate_command( + &self, + doc: &RestDescription, + mut cli: clap::Command, + ) -> clap::Command { + let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + let help_text = var + .description + .clone() + .unwrap_or_else(|| { + format!("Value for the {{{}}} URL template variable", var.name) + }); + let mut arg = clap::Arg::new(var.name.clone()) + .long(kebab) + .global(true) + .value_name(var.name.to_uppercase()) + .help(help_text); + if let Some(env) = &var.env_var { + arg = arg.env(env.clone()); + } + if let Some(default) = &var.default { + arg = arg.default_value(default.clone()); + } + cli = cli.arg(arg); + } + + // SDK-variable flags (`x-fern-sdk-variables`). + for var in &doc.sdk_variables { + let kebab = crate::text::to_kebab_flag(&var.name); + if sdk_variable_collides_with_builtin(&kebab) { + tracing::warn!( + variable = %var.name, + flag = %kebab, + "SDK variable flag collides with built-in; skipping" + ); + continue; + } + let screaming = crate::text::to_screaming_snake(&var.name); + let mut arg = clap::Arg::new(var.name.clone()) + .long(kebab) + .global(true) + .value_name(screaming.clone()) + .env(screaming); + if let Some(desc) = &var.description { + arg = arg.help(desc.clone()); + } + cli = cli.arg(arg); + } + + // Global-header flags (`x-fern-global-headers`). + use std::collections::HashSet; + let mut registered_kebabs: HashSet = HashSet::new(); + let mut global_header_help_pairs: Vec<(String, String)> = Vec::new(); + for h in &doc.global_headers { + let kebab = global_header_flag_name(h); + if global_header_flag_collides_with_builtin(&kebab) { + tracing::warn!( + header = %h.header, + flag = %kebab, + "Global-header flag collides with built-in; skipping" + ); + continue; + } + if !registered_kebabs.insert(kebab.clone()) { + tracing::warn!( + header = %h.header, + flag = %kebab, + "Duplicate global-header flag; skipping" + ); + continue; + } + let arg_id = global_header_arg_id(h); + let value_name = crate::text::to_screaming_snake(&kebab); + let mut help_lines: Vec = + vec![format!("Global header `{}` (sent on every request).", h.header)]; + if let Some(env) = &h.env { + help_lines.push(format!("Env: {env}.")); + } + if let Some(def) = &h.default { + help_lines.push(format!("Default: {def}.")); + } else if !h.optional { + help_lines.push("Required.".to_string()); + } + let help_text = help_lines.join(" "); + let prefix = format!("--{kebab} <{value_name}>"); + global_header_help_pairs.push((prefix, help_text.clone())); + let mut arg = clap::Arg::new(arg_id) + .long(kebab) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); + } + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); + } + + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → auth → env vars. + let existing_after_help = cli.get_after_help().map(|s| s.to_string()); + let global_headers_section: Option = if global_header_help_pairs.is_empty() { + None + } else { + let prefix_width = global_header_help_pairs + .iter() + .map(|(p, _)| p.chars().count()) + .max() + .unwrap_or(0); + let rows: Vec = global_header_help_pairs + .iter() + .map(|(prefix, help)| { + let pad = prefix_width.saturating_sub(prefix.chars().count()); + format!(" {prefix}{:pad$} {help}", "", pad = pad) + }) + .collect(); + Some(format!("Global headers:\n{}", rows.join("\n"))) + }; + let env_footer = super::commands::after_help_footer(&doc.name); + let base_footer = match existing_after_help { + Some(ref s) if !s.is_empty() => format!("{s}\n{env_footer}"), + _ => env_footer, + }; + cli = cli.after_help(compose_root_after_help_sections( + global_headers_section.as_deref(), + auth_section.as_deref(), + &base_footer, + )); + + cli + } + + /// Resolve server variable values from clap matches and substitute + /// them into the doc's URLs. + pub(crate) fn apply_server_vars( + &self, + doc: &mut RestDescription, + matches: &clap::ArgMatches, + ) { + let mut subs = std::collections::HashMap::new(); + for var in &self.server_vars { + if let Some(val) = matches.get_one::(&var.name) { + subs.insert(var.name.clone(), val.clone()); + } + } + apply_server_var_substitutions(doc, &subs); + } + + /// Handle the `generate-skills` subcommand: validate the output + /// path, emit SKILL.md files, and report to stderr. + pub(crate) fn handle_generate_skills( + &self, + output_dir: Option<&str>, + doc: &RestDescription, + ) -> Result<(), CliError> { + let out_dir = output_dir.unwrap_or("skills").to_string(); + let resolved = crate::validate::validate_safe_output_dir(&out_dir)?; + + let files = + crate::openapi::skill_emitter::generate_skills(doc, &self.name, &self.auth_bindings); + + for (rel_path, content) in &files { + let full_path = resolved.join(rel_path); + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CliError::Validation(format!( + "Failed to create directory {}: {e}", + parent.display() + )) + })?; + } + std::fs::write(&full_path, content).map_err(|e| { + CliError::Validation(format!( + "Failed to write {}: {e}", + full_path.display() + )) + })?; + } + + eprintln!( + "Wrote {} skill file(s) to {}/", + files.len(), + resolved.display() + ); + Ok(()) + } + + /// Construct the [`DynAuthProvider`] used for this run from the + /// registered bindings. With no bindings, returns a `NoAuthProvider` + /// — the CLI runs unauthenticated. + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); + crate::auth::build_provider_with_strategy( + &self.auth_bindings, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } + + /// Build an auth provider from externally-finalized bindings. + /// Used by `OpenApiBinding::dispatch` after CLI-bound auth sources + /// have been resolved against the parsed clap matches. + pub(crate) fn build_auth_provider_from_finalized( + &self, + finalized: &[(String, crate::auth::SchemeBinding)], + doc: &RestDescription, + ) -> DynAuthProvider { + let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// One binding's worth of prepared state inside an [`AppContext`]. +/// +/// When a CLI registers multiple `OpenApiBinding`s, each contributes one +/// entry. Method lookups and execution are routed to the entry whose +/// spec owns the target method. +pub(crate) struct BindingEntry { + pub(crate) doc: RestDescription, + pub(crate) auth_provider: DynAuthProvider, + pub(crate) http_config: crate::http::HttpConfig, + pub(crate) global_headers: Vec<(String, String)>, +} + +/// Runtime context passed to custom command handlers. +/// +/// Provides access to the loaded API spec(s), the constructed auth +/// provider(s), and convenience methods for executing API methods. +/// +/// When multiple `OpenApiBinding`s are registered on the same `CliApp`, +/// `AppContext` holds all of their specs. Method lookups and +/// `execute()`/`invoke()` calls are automatically routed to the binding +/// that owns the target method — callers do not need to know which +/// binding a method came from. +pub struct AppContext { + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, +} + +impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + self + } + + /// Add another binding's prepared state to this context. + pub(crate) fn add_entry(&mut self, entry: BindingEntry) { + self.entries.push(entry); + } + + /// Find which entry owns `method` by pointer identity. + fn entry_for_method(&self, method: &RestMethod) -> &BindingEntry { + for entry in &self.entries { + if resource_tree_contains_method(&entry.doc.resources, method) { + return entry; + } + } + &self.entries[0] + } + + /// Compute the per-op `extra_headers` slice from the pre-resolved + /// global headers, suppressing entries whose wire-name is also + /// supplied as a per-op `header` parameter via `params_json` + /// (per-op wins, mirroring the built-in command path). + /// + /// Required-header validation lives here rather than at + /// `AppContext` construction time because per-op overrides depend + /// on the specific operation being invoked: a required global + /// header with no resolved value is allowed when the operation + /// itself declares the same header as a per-op parameter (the + /// per-op value takes its place on the wire). This mirrors + /// `build_global_header_overrides` on the built-in command path so + /// custom-command handlers get the same validation error shape. + #[cfg(test)] + fn extra_headers_for( + &self, + method: &RestMethod, + params_json: Option<&str>, + ) -> Result, CliError> { + let entry = self.entry_for_method(method); + self.extra_headers_for_entry(entry, method, params_json) + } + + fn extra_headers_for_entry( + &self, + entry: &BindingEntry, + method: &RestMethod, + params_json: Option<&str>, + ) -> Result, CliError> { + let params: serde_json::Map = match params_json { + Some(s) if !s.trim().is_empty() => serde_json::from_str(s) + .map_err(|e| CliError::Validation(format!("Invalid params JSON: {e}")))?, + _ => serde_json::Map::new(), + }; + // HTTP header names are case-insensitive per RFC 7230 §3.2 — key + // the lookup table by lowercased wire-name so a custom-command + // handler that resolved `x-api-stage` still satisfies the spec's + // declared `X-API-Stage` global. + let resolved_by_wire: std::collections::HashMap = entry + .global_headers + .iter() + .map(|(n, v)| (n.to_ascii_lowercase(), v.as_str())) + .collect(); + finalize_global_header_overrides(&entry.doc.global_headers, method, ¶ms, |h| { + resolved_by_wire + .get(&h.header.to_ascii_lowercase()) + .map(|v| (*v).to_string()) + }) + } + + /// Execute an API method by name, using the same executor as built-in + /// commands. Automatically routes to the binding that owns `method`. + pub fn execute( + &self, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + output_format: &formatter::OutputFormat, + ) -> Result<(), CliError> { + let entry = self.entry_for_method(method); + let pagination = executor::PaginationConfig { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + token_query_param: entry + .doc + .pagination_token_query_param + .clone() + .unwrap_or_else(|| "pageToken".to_string()), + token_response_path: entry + .doc + .pagination_token_response_path + .clone() + .unwrap_or_else(|| "nextPageToken".to_string()), + }; + + let pipeline = formatter::OutputPipeline { + format: output_format.clone(), + color_mode: formatter::ColorMode::default(), + quiet: self.quiet, + }; + let extra_headers = self.extra_headers_for_entry(entry, method, params_json)?; + + // Custom commands dispatch from inside `run_async`, which is itself + // driven by a tokio runtime. Naively calling `block_on` from a sync + // handler panics ("Cannot start a runtime from within a runtime"). + // `block_in_place` parks the current worker so `block_on` is legal. + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(executor::execute_method( + &entry.doc, + method, + params_json, + body_json, + &entry.auth_provider, + None, + None, + None, + false, + &pagination, + &pipeline, + false, + None, + &entry.http_config, + // TODO(mcp/programmatic): programmatic callers always + // honor `x-fern-sdk-return-value` (matches typed-SDK + // semantics). If/when an MCP-tool surface wraps this + // path and needs to expose `--no-extract` to its + // clients, plumb a flag through `AppContext::execute` + // rather than flipping this constant. + false, + // Programmatic callers always honor `x-fern-retries` + // — the debug-only `--no-retry` flag is intentionally + // a CLI-only surface. If/when an MCP-tool path needs + // to disable retries for stability/debugging, plumb + // a flag through `AppContext::execute` rather than + // flipping this constant. + false, + // Same trade-off for `--no-stream`: programmatic callers + // chaining streaming endpoints almost always want the + // events emitted as they arrive (stdout printing path); + // forcing buffered mode here would block the entire + // response in memory. The CLI surface keeps the + // streaming default; only the CLI front-end exposes the + // opt-in buffered toggle. + false, + &extra_headers, + )) + }) + .map(|_| ()) + } + + /// Invoke an API method and return the parsed JSON response. + /// + /// Like [`execute`](Self::execute) but captures the response instead of + /// printing it, and accepts a `binary_body_path` for operations with a + /// binary request body (e.g. a file upload endpoint). Designed for + /// custom commands that chain multiple API calls. + pub fn invoke( + &self, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + binary_body_path: Option<&str>, + ) -> Result { + let entry = self.entry_for_method(method); + let pagination = executor::PaginationConfig { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + token_query_param: entry + .doc + .pagination_token_query_param + .clone() + .unwrap_or_else(|| "pageToken".to_string()), + token_response_path: entry + .doc + .pagination_token_response_path + .clone() + .unwrap_or_else(|| "nextPageToken".to_string()), + }; + + let extra_headers = self.extra_headers_for_entry(entry, method, params_json)?; + // See note in `execute` — `block_in_place` is required because the + // handler runs inside the outer tokio runtime. + let value = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(executor::execute_method( + &entry.doc, + method, + params_json, + body_json, + &entry.auth_provider, + None, + None, + binary_body_path, + false, + &pagination, + &formatter::OutputPipeline::default(), + true, // capture_output + None, + &entry.http_config, + // See TODO in `execute` above — same trade-off applies + // here: chained custom commands expect the + // spec-promised subvalue, not the raw envelope. + false, + // Programmatic callers always honor `x-fern-retries` + // (see note in `execute`). + false, + // `invoke` captures the response into a `serde_json::Value` + // for callers that chain multiple API calls. Stream-mode + // makes no sense here — the executor would have to invent + // an ordering decision (last event? array of events?) + // when the caller just wants a typed value back. Force + // buffered semantics so the captured value mirrors the + // unary-response shape callers already handle. + true, + &extra_headers, + )) + })?; + + value.ok_or_else(|| { + CliError::Other(anyhow::anyhow!( + "API method returned no value (non-JSON or empty body)" + )) + }) + } + + /// Returns a reference to the loaded API spec. + /// + /// When multiple `OpenApiBinding`s are registered, this returns the + /// first binding's spec. Use [`find_method`](Self::find_method) to + /// search across all bindings. + pub fn spec(&self) -> &RestDescription { + &self.entries[0].doc + } + + /// Returns references to all loaded API specs. + /// + /// Each entry corresponds to one `OpenApiBinding` registered on the + /// `CliApp`. For single-binding CLIs the slice has exactly one element. + pub fn specs(&self) -> Vec<&RestDescription> { + self.entries.iter().map(|e| &e.doc).collect() + } + + /// Search all registered specs for a method at `resource.method_name`. + /// + /// This is the recommended way to look up methods in a multi-binding + /// CLI — it searches across all bindings and returns the first match. + pub fn find_method( + &self, + resource: &str, + method_name: &str, + ) -> Result<&RestMethod, CliError> { + for entry in &self.entries { + if let Some(r) = entry.doc.resources.get(resource) { + if let Some(m) = r.methods.get(method_name) { + return Ok(m); + } + } + } + Err(CliError::Validation(format!( + "no method '{method_name}' found in resource '{resource}' across {} binding(s)", + self.entries.len(), + ))) + } + + /// Returns a reference to the HTTP/TLS configuration for this CLI run. + /// + /// Holds the binary name (used to scope `_*` env vars) and any + /// compile-time trust roots. Non-reqwest transports — e.g. the + /// [`websocket`](crate::websocket) module — call + /// [`HttpConfig::resolve`](crate::http::HttpConfig::resolve) on this to + /// build their own TLS connectors while honoring the same env vars + /// users already configure for the HTTP path. + /// + /// Auth credentials are intentionally *not* exposed via `AppContext`: + /// transports needing a credential value take an + /// [`AuthCredentialSource`](crate::auth::AuthCredentialSource) directly + /// at the call site. See `docs/adr/0001-auth-provider-no-cred-extraction.md`. + pub fn http_config(&self) -> &crate::http::HttpConfig { + &self.entries[0].http_config + } + +} + +/// Recursively check whether any method in the resource tree is the +/// same object (pointer-equal) as `target`. Used by +/// [`AppContext::entry_for_method`] to route `execute()`/`invoke()` +/// to the correct binding's auth and HTTP config. +fn resource_tree_contains_method( + resources: &std::collections::HashMap, + target: &RestMethod, +) -> bool { + for resource in resources.values() { + for m in resource.methods.values() { + if std::ptr::eq(m, target) { + return true; + } + } + if resource_tree_contains_method(&resource.resources, target) { + return true; + } + } + false +} + +/// Walk a resource (and its sub-resources) for any method that declares +/// `security_requirements`. Used by `build_auth_provider` to feed the +/// per-endpoint flag into `build_provider_with_strategy`. +fn resource_has_per_endpoint_security(resource: &RestResource) -> bool { + if resource + .methods + .values() + .any(|m| m.security_requirements.is_some()) + { + return true; + } + resource.resources.values().any(resource_has_per_endpoint_security) +} + +/// Recursively walks clap ArgMatches to find the leaf method and its matches. +pub fn resolve_method_from_matches<'a>( + doc: &'a RestDescription, + matches: &'a clap::ArgMatches, +) -> Result<(&'a RestMethod, &'a clap::ArgMatches), CliError> { + let mut path: Vec<&str> = Vec::new(); + let mut current_matches = matches; + + while let Some((sub_name, sub_matches)) = current_matches.subcommand() { + path.push(sub_name); + current_matches = sub_matches; + } + + if path.is_empty() { + return Err(CliError::Validation( + "No resource or method specified".to_string(), + )); + } + + let resource_name = path[0]; + let resource = doc + .resources + .get(resource_name) + .ok_or_else(|| CliError::Validation(format!("Resource '{resource_name}' not found")))?; + + let mut current_resource = resource; + + for &name in &path[1..path.len() - 1] { + if let Some(sub) = current_resource.resources.get(name) { + current_resource = sub; + } else { + return Err(CliError::Validation(format!( + "Sub-resource '{name}' not found" + ))); + } + } + + let method_name = path[path.len() - 1]; + + if let Some(method) = current_resource.methods.get(method_name) { + return Ok((method, current_matches)); + } + + Err(CliError::Validation(format!( + "Method '{method_name}' not found on resource. Available methods: {:?}", + current_resource.methods.keys().collect::>() + ))) +} + +/// Collect individual flag values into a params map. +/// Values from --params JSON override individual flags. +/// +/// When a parameter has a `default_value` from `x-fern-default` and +/// the user did not supply the flag, clap surfaces the default as a +/// stringified value. We detect this via `ArgMatches::value_source` +/// and substitute the originally-typed JSON so numbers and booleans +/// keep their wire type — strings pass through unchanged. +/// +/// Parameters whose only default comes from the OpenAPI standard +/// `default:` keyword (stored on `documentation_default_value`) do +/// **not** get a clap default, so `get_one` returns `None` and the +/// `let-else continue` below correctly omits them from the outgoing +/// request — the API server applies its own default. +pub(crate) fn collect_params_from_flags( + matched_args: &clap::ArgMatches, + method: &crate::openapi::discovery::RestMethod, + params_override: Option<&str>, +) -> Result, CliError> { + let mut params = serde_json::Map::new(); + + // Collect values from individual flags. Three extensions interact here: + // + // 1. `x-fern-sdk-variable`: variable-bound path params are NOT + // registered as per-op flags (see `commands::build_resource_command`); + // their value comes from the root-level global flag registered in + // `run_async` from `doc.sdk_variables`. clap propagates global args + // down to subcommand matches so we look them up by the variable + // name on the same `matched_args`. If the global is unset, defer + // the validation error until AFTER the `--params` JSON override is + // applied below — `--params` is documented as "overrides individual + // flags" and must be allowed to act as a fallback here too, + // mirroring how plain path params behave when their per-op flag is + // absent. + // + // 2. `x-fern-default`: when clap surfaced an `x-fern-default` value + // (i.e. the user omitted the flag and the parameter had a + // `default_value` populated by `x-fern-default`), use the + // originally-typed JSON value so numbers/booleans keep their + // wire type instead of arriving as strings. + // + // 3. `x-fern-enum`: for user-supplied values on (non-variable-bound) + // parameters that declare enum aliases, resolve the display + // alias back to the wire value so the executor only ever sees + // what the server expects. + let mut missing_variable_bound: Vec<(String, String)> = Vec::new(); + for (param_name, param_def) in &method.parameters { + if let Some(var_name) = param_def.variable_reference.as_deref() { + // Global flag ids match the variable name (see `run_async`). + // clap's `.env(...)` on the global arg already covers the + // env-var fallback before we get here, so a missing value + // means neither CLI flag nor env var was provided. + match matched_args.get_one::(var_name) { + Some(value) => { + params.insert( + param_name.clone(), + serde_json::Value::String(value.clone()), + ); + } + None => { + missing_variable_bound.push((param_name.clone(), var_name.to_string())); + } + } + continue; + } + if param_def.repeated { + if let Some(values) = matched_args.get_many::(param_name) { + let arr: Vec = values + .map(|v| serde_json::Value::String(v.clone())) + .collect(); + params.insert(param_name.clone(), serde_json::Value::Array(arr)); + } + continue; + } + + let Some(value) = matched_args.get_one::(param_name) else { + continue; + }; + let from_default = matched_args.value_source(param_name) + == Some(clap::parser::ValueSource::DefaultValue); + let json_value = match (from_default, ¶m_def.default_value) { + (true, Some(typed)) => typed.clone(), + _ => { + // For object-typed params (e.g. deepObject query parameters), + // attempt JSON parsing so deepObject serialization receives a + // Value::Object rather than a string. + if param_def.param_type.as_deref() == Some("object") { + serde_json::from_str(value.as_str()) + .unwrap_or_else(|_| serde_json::Value::String(value.clone())) + } else { + let wire = param_def + .resolve_enum_display_to_wire(value.as_str()) + .into_owned(); + serde_json::Value::String(wire) + } + } + }; + params.insert(param_name.clone(), json_value); + } + + // Override with --params JSON if provided (--params wins). + if let Some(json_str) = params_override { + let overrides: serde_json::Map = + serde_json::from_str(json_str) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))?; + for (key, value) in overrides { + params.insert(key, value); + } + } + + // Now that --params has had its say, check whether any variable-bound + // parameter is still unsupplied. Only then emit the validation error + // naming both the global CLI flag and its env-var fallback. + for (param_name, var_name) in missing_variable_bound { + if !params.contains_key(¶m_name) { + let kebab = crate::text::to_kebab_flag(&var_name); + let env = crate::text::to_screaming_snake(&var_name); + return Err(CliError::Validation(format!( + "Missing required SDK variable '{var_name}': provide --{kebab}, \ + set ${env}, or include it in --params" + ))); + } + } + + Ok(params) +} + +pub(crate) fn build_pagination_config( + matches: &clap::ArgMatches, + doc: &RestDescription, +) -> executor::PaginationConfig { + executor::PaginationConfig { + page_all: matches.get_flag("page-all"), + page_limit: matches + .get_one::("page-limit") + .copied() + .unwrap_or(10), + page_delay_ms: matches + .get_one::("page-delay") + .copied() + .unwrap_or(100), + token_query_param: doc + .pagination_token_query_param + .clone() + .unwrap_or_else(|| "pageToken".to_string()), + token_response_path: doc + .pagination_token_response_path + .clone() + .unwrap_or_else(|| "nextPageToken".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ------------------------------------------------------------------ + // x-fern-global-headers (FER-9864 P2) — registration helpers. + // ------------------------------------------------------------------ + + /// `global_header_flag_name` honors `name:` (kebab-cased) when set, + /// otherwise falls back to kebab-casing the wire header. This is + /// the same precedence the upstream Fern importer uses. + #[test] + fn test_global_header_flag_name_respects_name_field_then_header() { + let h_with_name = crate::openapi::discovery::GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }; + assert_eq!(global_header_flag_name(&h_with_name), "api-stage"); + + let h_no_name = crate::openapi::discovery::GlobalHeader { + header: "X-Tenant-Id".into(), + name: None, + optional: false, + env: None, + default: None, + }; + assert_eq!(global_header_flag_name(&h_no_name), "x-tenant-id"); + } + + /// The clap arg ID for a global header must be namespaced so it + /// can't collide with any per-op parameter HashMap key. The wire + /// header is preserved verbatim so the executor's lookup against + /// `RestMethod.parameters` stays straightforward. + #[test] + fn test_global_header_arg_id_is_namespaced_by_wire_name() { + let h = crate::openapi::discovery::GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }; + assert_eq!(global_header_arg_id(&h), "__global_header::X-API-Stage"); + } + + /// `build_global_header_overrides` errors with a message naming the + /// flag, env var, and wire-header name when a required header has + /// no value source. Pins the human-facing error shape required by + /// the FER-9864 acceptance criteria ("required-without-value + /// fails"), at the level where the validation actually lives. + #[test] + fn test_build_global_header_overrides_errors_when_required_missing() { + use crate::openapi::discovery::{GlobalHeader, RestDescription, RestMethod}; + use clap::Command; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: Some("FIXTURE_API_STAGE".into()), + default: None, + }], + ..Default::default() + }; + let method = RestMethod::default(); + + // Use a clap Command with NO defaults bound for the arg — + // simulating "user passed nothing, env unset, no default". + let cmd = Command::new("test").arg( + clap::Arg::new(global_header_arg_id(&doc.global_headers[0])) + .long(global_header_flag_name(&doc.global_headers[0])) + .global(true), + ); + let matches = cmd.try_get_matches_from(["test"]).unwrap(); + let params = serde_json::Map::new(); + + let err = build_global_header_overrides(&matches, &doc, &method, ¶ms).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("--api-stage"), + "error should name the CLI flag: {msg}" + ); + assert!( + msg.contains("FIXTURE_API_STAGE"), + "error should name the env var: {msg}" + ); + assert!( + msg.contains("X-API-Stage"), + "error should name the wire header: {msg}" + ); + } + + /// When the user supplies a per-op header parameter with the same + /// wire-name as a global header, the per-op value wins and the + /// global is dropped from the override list. Mirrors the upstream + /// Fern importer's per-op-wins behavior so operators get a single + /// override surface for collision cases. + #[test] + fn test_build_global_header_overrides_per_op_param_wins() { + use crate::openapi::discovery::{ + GlobalHeader, MethodParameter, RestDescription, RestMethod, + }; + use clap::Command; + use std::collections::HashMap; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: Some("production".into()), + }], + ..Default::default() + }; + + // Per-op method declares `X-API-Stage` as a header parameter. + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + "X-API-Stage".into(), + MethodParameter { + location: Some("header".into()), + ..Default::default() + }, + ); + let method = RestMethod { + parameters, + ..Default::default() + }; + + // Simulate clap matches with the global default applied. + let cmd = Command::new("test").arg( + clap::Arg::new(global_header_arg_id(&doc.global_headers[0])) + .long(global_header_flag_name(&doc.global_headers[0])) + .default_value("production") + .global(true), + ); + let matches = cmd.try_get_matches_from(["test"]).unwrap(); + + // The per-op `params` map contains a value for the same wire-name. + let mut params = serde_json::Map::new(); + params.insert("X-API-Stage".into(), serde_json::json!("canary")); + + let overrides = + build_global_header_overrides(&matches, &doc, &method, ¶ms).unwrap(); + assert!( + overrides.is_empty(), + "per-op param suppresses the global override, got: {overrides:?}", + ); + } + + #[test] + fn test_sdk_variable_collides_with_builtin_flags() { + // Variables whose kebab form matches any built-in per-op flag + // must be flagged as colliding so the global registration site + // can skip them with a warning instead of letting clap panic. + // Cover the names that are most likely to be picked accidentally. + for builtin in ["params", "format", "dry-run", "base-url", "page-all", "output", "json"] { + assert!( + sdk_variable_collides_with_builtin(builtin), + "expected '{builtin}' to collide with a built-in flag", + ); + } + // Plain identifiers and innocuous variable names must NOT collide. + for ok in ["garden-id", "tenant-id", "page-token", "uuid", "client-id"] { + assert!( + !sdk_variable_collides_with_builtin(ok), + "expected '{ok}' NOT to collide with a built-in flag", + ); + } + } + + #[test] + fn test_cli_app_builder() { + let app = CliApp::new("test-cli") + .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") + .auth_scheme_env("bearer", "TEST_TOKEN"); + + assert_eq!(app.name, "test-cli"); + assert_eq!(app.specs.len(), 1); + assert_eq!(app.auth_bindings.len(), 1); + assert_eq!(app.auth_bindings[0].0, "bearer"); + } + + #[test] + fn test_auth_scheme_records_token_binding() { + let app = CliApp::new("t") + .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") + .auth_scheme("bearerAuth", AuthCredentialSource::from_env("API_TOKEN")); + assert_eq!(app.auth_bindings.len(), 1); + assert_eq!(app.auth_bindings[0].0, "bearerAuth"); + match &app.auth_bindings[0].1 { + SchemeBinding::Token(_) => {} + other => panic!("expected Token, got {other:?}"), + } + } + + #[test] + fn test_auth_basic_scheme_records_basic_binding() { + let app = CliApp::new("t") + .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") + .auth_basic_scheme( + "basic", + AuthCredentialSource::from_env("U"), + AuthCredentialSource::from_env("P"), + ); + assert!(matches!( + app.auth_bindings[0].1, + SchemeBinding::Basic { .. }, + )); + } + + #[test] + fn test_resolve_method_from_matches_basic() { + let mut resources = std::collections::HashMap::new(); + let mut files_res = crate::openapi::discovery::RestResource::default(); + files_res.methods.insert( + "list".to_string(), + crate::openapi::discovery::RestMethod { + id: Some("files.list".to_string()), + http_method: "GET".to_string(), + ..Default::default() + }, + ); + resources.insert("files".to_string(), files_res); + + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let cmd = clap::Command::new("cli") + .subcommand(clap::Command::new("files").subcommand(clap::Command::new("list"))); + + let matches = cmd.get_matches_from(vec!["cli", "files", "list"]); + let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap(); + assert_eq!(method.id.as_deref(), Some("files.list")); + } + + #[test] + fn test_resolve_method_from_matches_nested() { + let mut resources = std::collections::HashMap::new(); + let mut files_res = crate::openapi::discovery::RestResource::default(); + let mut permissions_res = crate::openapi::discovery::RestResource::default(); + permissions_res.methods.insert( + "get".to_string(), + crate::openapi::discovery::RestMethod { + id: Some("files.permissions.get".to_string()), + ..Default::default() + }, + ); + files_res + .resources + .insert("permissions".to_string(), permissions_res); + resources.insert("files".to_string(), files_res); + + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let cmd = + clap::Command::new("cli").subcommand(clap::Command::new("files").subcommand( + clap::Command::new("permissions").subcommand(clap::Command::new("get")), + )); + + let matches = cmd.get_matches_from(vec!["cli", "files", "permissions", "get"]); + let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap(); + assert_eq!(method.id.as_deref(), Some("files.permissions.get")); + } + + #[test] + fn test_resolve_method_empty_path() { + let doc = RestDescription { + name: "test".to_string(), + ..Default::default() + }; + + let cmd = clap::Command::new("cli"); + let matches = cmd.get_matches_from(vec!["cli"]); + let result = resolve_method_from_matches(&doc, &matches); + assert!(result.is_err()); + } + + /// `AppContext::extra_headers_for` mirrors the built-in command + /// path: a required global header with no resolved value and no + /// per-op override fails with a validation error that names both + /// the CLI flag and the env var. This is the regression test for + /// the custom-command-handler path that previously dropped the + /// header silently. + #[test] + fn test_app_context_extra_headers_required_missing_errors() { + use crate::openapi::discovery::{GlobalHeader, RestDescription, RestMethod}; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: Some("FIXTURE_API_STAGE".into()), + default: None, + }], + ..Default::default() + }; + let ctx = AppContext::new( + doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + // Note: the custom-command path's filter_map silently + // dropped this required header. With the fix, + // extra_headers_for surfaces a validation error. + Vec::new(), + ); + let method = RestMethod::default(); + let err = ctx.extra_headers_for(&method, None).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("--api-stage"), "should name flag: {msg}"); + assert!(msg.contains("FIXTURE_API_STAGE"), "should name env: {msg}"); + assert!(msg.contains("X-API-Stage"), "should name wire header: {msg}"); + } + + /// A required global header with no resolved value is permitted + /// when the operation itself declares a same-named header + /// parameter that the user supplied — the per-op value will be + /// sent on the wire in place of the global. Mirrors the built-in + /// command path's per-op-wins behavior. + #[test] + fn test_app_context_extra_headers_per_op_param_satisfies_required() { + use crate::openapi::discovery::{ + GlobalHeader, MethodParameter, RestDescription, RestMethod, + }; + use std::collections::HashMap; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }], + ..Default::default() + }; + let ctx = AppContext::new( + doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + "X-API-Stage".into(), + MethodParameter { + location: Some("header".into()), + ..Default::default() + }, + ); + let method = RestMethod { + parameters, + ..Default::default() + }; + let params_json = r#"{"X-API-Stage":"canary"}"#; + let headers = ctx + .extra_headers_for(&method, Some(params_json)) + .expect("per-op override should satisfy the required global header"); + assert!(headers.is_empty(), "per-op wins: globals dropped: {headers:?}"); + } + + /// An optional global header with no resolved value is silently + /// omitted (no error). Pins the negative case so a future + /// over-strict change to the required-header guard doesn't start + /// failing optional headers too. + #[test] + fn test_app_context_extra_headers_optional_missing_is_ok() { + use crate::openapi::discovery::{GlobalHeader, RestDescription, RestMethod}; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-Tenant-Id".into(), + name: None, + optional: true, + env: None, + default: None, + }], + ..Default::default() + }; + let ctx = AppContext::new( + doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); + let method = RestMethod::default(); + let headers = ctx.extra_headers_for(&method, None).expect("optional ok"); + assert!(headers.is_empty(), "optional with no value: {headers:?}"); + } + + /// Multi-spec merge: when two specs declare the same wire-name in + /// `x-fern-global-headers`, the first write wins and the second is + /// silently dropped. Mirrors `merge_sdk_variables` and keeps the + /// resolved flag registry deterministic across spec ordering. + #[test] + fn test_merge_global_headers_first_write_wins() { + use crate::openapi::discovery::GlobalHeader; + + let mut acc = vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: Some("FIRST_STAGE".into()), + default: Some("production".into()), + }]; + let incoming = vec![ + // Same wire-name → must be dropped, preserving the first env / default. + GlobalHeader { + header: "X-API-Stage".into(), + name: Some("stage".into()), + optional: true, + env: Some("SECOND_STAGE".into()), + default: Some("staging".into()), + }, + // Distinct wire-name → must be appended. + GlobalHeader { + header: "X-Tenant-Id".into(), + name: None, + optional: true, + env: None, + default: None, + }, + ]; + merge_global_headers(&mut acc, incoming); + assert_eq!(acc.len(), 2, "got: {acc:?}"); + assert_eq!(acc[0].header, "X-API-Stage"); + assert_eq!(acc[0].env.as_deref(), Some("FIRST_STAGE")); + assert_eq!(acc[0].default.as_deref(), Some("production")); + assert!(!acc[0].optional); + assert_eq!(acc[1].header, "X-Tenant-Id"); + } + + /// Per-op-override match must be case-insensitive per RFC 7230 §3.2. + /// A spec that declares `X-API-Stage` globally and `x-api-stage` as a + /// header param on a single op should treat them as the same header + /// — the per-op value wins and the global is suppressed (rather than + /// both landing on the wire). + #[test] + fn test_per_op_header_param_override_is_case_insensitive() { + use crate::openapi::discovery::{GlobalHeader, MethodParameter, RestDescription, RestMethod}; + use std::collections::HashMap; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }], + ..Default::default() + }; + // Per-op param uses lowercase wire-name; case-insensitive lookup + // must still treat this as an override of the global. + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + "x-api-stage".into(), + MethodParameter { + location: Some("header".into()), + ..Default::default() + }, + ); + let method = RestMethod { + parameters, + ..Default::default() + }; + let ctx = AppContext::new( + doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); + // User supplied the per-op param under a third casing — the + // override should still kick in, satisfying the required check + // without a CLI flag / env value. + let headers = ctx + .extra_headers_for(&method, Some(r#"{"X-Api-Stage": "canary"}"#)) + .expect( + "per-op override should satisfy required-header check regardless of casing", + ); + assert!( + headers.is_empty(), + "global header must be suppressed when per-op param overrides it: {headers:?}", + ); + } + + /// `--api-stage ""` (or trimming-only whitespace) must NOT resolve + /// to `Some("")`. `resolve_global_header_value` trims and treats + /// empties as "no value supplied", so the required-header guard + /// fires instead of silently sending an empty `X-API-Stage:` header. + /// Pins the fix for the self-review finding noted in PR #45. + #[test] + fn test_resolve_global_header_value_filters_empty_and_whitespace() { + use crate::openapi::discovery::GlobalHeader; + + let h = GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }; + let cmd = clap::Command::new("t").arg( + clap::Arg::new(global_header_arg_id(&h)) + .long(global_header_flag_name(&h)), + ); + // Empty string flag value → None. + let m = cmd.clone().get_matches_from(["t", "--api-stage", ""]); + assert!( + resolve_global_header_value(&m, &h).is_none(), + "empty flag value must resolve to None", + ); + // Whitespace-only flag value → None. + let m = cmd.clone().get_matches_from(["t", "--api-stage", " "]); + assert!( + resolve_global_header_value(&m, &h).is_none(), + "whitespace-only flag value must resolve to None", + ); + // Normal value → Some(trimmed). + let m = cmd.get_matches_from(["t", "--api-stage", " canary "]); + assert_eq!( + resolve_global_header_value(&m, &h).as_deref(), + Some("canary"), + ); + } + + /// `compose_root_after_help_sections` joins present sections with + /// the footer and skips any `None` sections cleanly. Pins the + /// neither-auth-nor-global-headers regression target raised in PR + /// #45's self-review. + #[test] + fn test_compose_root_after_help_sections_skips_absent() { + let footer = "Standard env vars: …"; + let g = "Global headers:\n --api-stage …"; + let a = "Authentication:\n bearer …"; + + // Both absent: only the footer. + assert_eq!( + compose_root_after_help_sections(None, None, footer), + footer, + "no global headers, no auth → only the footer is rendered", + ); + // Auth only: same as the pre-FER-9864 baseline. + assert_eq!( + compose_root_after_help_sections(None, Some(a), footer), + format!("{a}\n{footer}"), + ); + // Globals only: no auth section. + assert_eq!( + compose_root_after_help_sections(Some(g), None, footer), + format!("{g}\n{footer}"), + ); + // Both present: globals first, then auth, then footer. + assert_eq!( + compose_root_after_help_sections(Some(g), Some(a), footer), + format!("{g}\n{a}\n{footer}"), + ); + } + + #[test] + fn test_app_context_spec_accessor() { + let doc = RestDescription { + name: "test".to_string(), + ..Default::default() + }; + let ctx = AppContext::new( + doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); + assert_eq!(ctx.spec().name, "test"); + } + + #[test] + fn test_find_method_across_entries() { + use std::collections::HashMap; + + let mut res_a = HashMap::new(); + let mut methods_a = HashMap::new(); + methods_a.insert("upload".to_string(), RestMethod { + id: Some("files.upload".to_string()), + ..Default::default() + }); + res_a.insert("files".to_string(), RestResource { + methods: methods_a, + ..Default::default() + }); + + let mut res_b = HashMap::new(); + let mut methods_b = HashMap::new(); + methods_b.insert("list".to_string(), RestMethod { + id: Some("users.list".to_string()), + ..Default::default() + }); + res_b.insert("users".to_string(), RestResource { + methods: methods_b, + ..Default::default() + }); + + let doc_a = RestDescription { + name: "spec-a".to_string(), + resources: res_a, + ..Default::default() + }; + let doc_b = RestDescription { + name: "spec-b".to_string(), + resources: res_b, + ..Default::default() + }; + + let mut ctx = AppContext::new( + doc_a, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); + ctx.add_entry(BindingEntry { + doc: doc_b, + auth_provider: crate::auth::no_auth_provider(), + http_config: crate::http::HttpConfig::new("test").unwrap(), + global_headers: Vec::new(), + }); + + // find_method should find methods from either entry. + let m1 = ctx.find_method("files", "upload").expect("should find files.upload"); + assert_eq!(m1.id.as_deref(), Some("files.upload")); + + let m2 = ctx.find_method("users", "list").expect("should find users.list"); + assert_eq!(m2.id.as_deref(), Some("users.list")); + + // entry_for_method routes to the correct entry. + let entry1 = ctx.entry_for_method(m1); + assert_eq!(entry1.doc.name, "spec-a"); + + let entry2 = ctx.entry_for_method(m2); + assert_eq!(entry2.doc.name, "spec-b"); + + // Missing method returns error. + assert!(ctx.find_method("orders", "get").is_err()); + + // specs() returns both. + assert_eq!(ctx.specs().len(), 2); + } + + #[test] + fn test_collect_params_individual_flags() { + let mut params = std::collections::HashMap::new(); + params.insert( + "uuid".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("uuid").long("uuid")) + .arg(clap::Arg::new("params").long("params")); + + let matches = cmd.get_matches_from(vec!["test", "--uuid", "abc-123"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!(result.get("uuid").unwrap().as_str().unwrap(), "abc-123"); + } + + #[test] + fn test_collect_params_override_wins() { + let mut params = std::collections::HashMap::new(); + params.insert( + "uuid".to_string(), + crate::openapi::discovery::MethodParameter::default(), + ); + + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("uuid").long("uuid")) + .arg(clap::Arg::new("params").long("params")); + + let matches = cmd.get_matches_from(vec![ + "test", + "--uuid", + "from-flag", + "--params", + r#"{"uuid":"from-json"}"#, + ]); + let override_str = matches.get_one::("params").map(|s| s.as_str()); + let result = collect_params_from_flags(&matches, &method, override_str).unwrap(); + assert_eq!(result.get("uuid").unwrap().as_str().unwrap(), "from-json"); + } + + #[test] + fn test_collect_params_empty_when_no_flags() { + let method = crate::openapi::discovery::RestMethod::default(); + let cmd = clap::Command::new("test").arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert!(result.is_empty()); + } + + // ------------------------------------------------------------------ + // CliApp::idempotency_header_env — generator-side env-var wiring for + // FER-9852, implemented in cli-sdk for FER-9864 P1. Verifies the + // builder overlays env vars on every idempotent operation's + // synthetic header MethodParameter (and skips non-idempotent + // siblings). + // ------------------------------------------------------------------ + + const IDEMPOTENCY_SPEC: &str = r#" +openapi: 3.0.2 +info: + title: Idempotency Builder Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + name: idempotency_key +paths: + /payments: + get: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: list + operationId: payments_list + responses: + "200": + description: ok + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + + #[test] + fn test_idempotency_header_env_matches_by_name() { + // Generator wires env var by `name` field (kebab/snake form, + // not the wire header). Should land on the idempotent op's + // synthetic param. + let doc = CliApp::new("test") + .spec(IDEMPOTENCY_SPEC) + .idempotency_header_env("idempotency_key", "API_IDEMPOTENCY_KEY") + .build_doc() + .unwrap(); + let create = &doc.resources["payments"].methods["create"]; + let p = create.parameters.get("Idempotency-Key").unwrap(); + assert_eq!(p.env_var.as_deref(), Some("API_IDEMPOTENCY_KEY")); + } + + #[test] + fn test_idempotency_header_env_matches_by_header() { + // Falls back to the wire header name when `name` isn't matched. + let doc = CliApp::new("test") + .spec(IDEMPOTENCY_SPEC) + .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") + .build_doc() + .unwrap(); + let create = &doc.resources["payments"].methods["create"]; + let p = create.parameters.get("Idempotency-Key").unwrap(); + assert_eq!(p.env_var.as_deref(), Some("API_IDEMPOTENCY_KEY")); + } + + #[test] + fn test_idempotency_header_env_skips_non_idempotent_ops() { + let doc = CliApp::new("test") + .spec(IDEMPOTENCY_SPEC) + .idempotency_header_env("idempotency_key", "API_IDEMPOTENCY_KEY") + .build_doc() + .unwrap(); + let list = &doc.resources["payments"].methods["list"]; + assert!(!list.idempotent); + assert!( + !list.parameters.contains_key("Idempotency-Key"), + "non-idempotent op must have no idempotency-header param at all", + ); + } + + fn pagination_cmd() -> clap::Command { + clap::Command::new("test") + .arg( + clap::Arg::new("page-all") + .long("page-all") + .action(clap::ArgAction::SetTrue), + ) + .arg( + clap::Arg::new("page-limit") + .long("page-limit") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + clap::Arg::new("page-delay") + .long("page-delay") + .value_parser(clap::value_parser!(u64)), + ) + } + + #[test] + fn test_build_pagination_config_defaults() { + let doc = RestDescription::default(); + let matches = pagination_cmd().get_matches_from(vec!["test"]); + let config = build_pagination_config(&matches, &doc); + assert!(!config.page_all); + assert_eq!(config.page_limit, 10); + assert_eq!(config.page_delay_ms, 100); + assert_eq!(config.token_query_param, "pageToken"); + assert_eq!(config.token_response_path, "nextPageToken"); + } + + #[test] + fn test_build_pagination_config_uses_doc_token_names() { + let doc = RestDescription { + pagination_token_query_param: Some("cursor".to_string()), + pagination_token_response_path: Some("meta.next_cursor".to_string()), + ..Default::default() + }; + let matches = pagination_cmd().get_matches_from(vec!["test"]); + let config = build_pagination_config(&matches, &doc); + assert_eq!(config.token_query_param, "cursor"); + assert_eq!(config.token_response_path, "meta.next_cursor"); + } + + #[test] + fn test_resolve_method_resource_not_found() { + let doc = RestDescription::default(); + let cmd = + clap::Command::new("cli").subcommand(clap::Command::new("unknown")); + let matches = cmd.get_matches_from(vec!["cli", "unknown"]); + let err = resolve_method_from_matches(&doc, &matches).unwrap_err(); + assert!(err.to_string().contains("Resource 'unknown' not found")); + } + + #[test] + fn test_resolve_method_method_not_found() { + let mut resources = std::collections::HashMap::new(); + resources.insert("files".to_string(), crate::openapi::discovery::RestResource::default()); + let doc = RestDescription { resources, ..Default::default() }; + + let cmd = clap::Command::new("cli") + .subcommand(clap::Command::new("files").subcommand(clap::Command::new("delete"))); + let matches = cmd.get_matches_from(vec!["cli", "files", "delete"]); + let err = resolve_method_from_matches(&doc, &matches).unwrap_err(); + assert!(err.to_string().contains("Method 'delete' not found")); + } + + #[test] + fn test_resolve_method_sub_resource_not_found() { + let mut resources = std::collections::HashMap::new(); + resources.insert("files".to_string(), crate::openapi::discovery::RestResource::default()); + let doc = RestDescription { resources, ..Default::default() }; + + let cmd = clap::Command::new("cli").subcommand( + clap::Command::new("files").subcommand( + clap::Command::new("permissions").subcommand(clap::Command::new("list")), + ), + ); + let matches = cmd.get_matches_from(vec!["cli", "files", "permissions", "list"]); + let err = resolve_method_from_matches(&doc, &matches).unwrap_err(); + assert!(err.to_string().contains("Sub-resource 'permissions' not found")); + } + + #[test] + fn test_collect_params_invalid_json_override() { + let method = crate::openapi::discovery::RestMethod::default(); + let cmd = clap::Command::new("test").arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test"]); + let err = + collect_params_from_flags(&matches, &method, Some("{not valid json}")).unwrap_err(); + assert!(err.to_string().contains("Invalid --params JSON")); + } + + #[test] + fn test_multi_spec_flat_merge() { + // Two specs with non-overlapping resources should merge + let spec_a = r#" +openapi: "3.0.0" +info: + title: "API A" + version: "1.0" +servers: + - url: "https://api-a.example.com" +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let spec_b = r#" +openapi: "3.0.0" +info: + title: "API B" + version: "1.0" +servers: + - url: "https://api-b.example.com" +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec(spec_a).spec(spec_b); + let doc = app.build_doc().unwrap(); + assert!(doc.resources.contains_key("users")); + assert!(doc.resources.contains_key("orders")); + } + + #[test] + fn test_multi_spec_collision_error() { + let spec = r#" +openapi: "3.0.0" +info: + title: "API" + version: "1.0" +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec(spec).spec(spec); + let result = app.build_doc(); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("users"), "error should name the colliding key"); + } + + #[test] + fn test_title_description_override() { + let spec = r#" +openapi: "3.0.0" +info: + title: "Original Title" + description: "Original description" + version: "1.0" +paths: {} +"#; + let app = CliApp::new("test") + .spec(spec) + .title("My Custom Title") + .description("My custom description"); + let doc = app.build_doc().unwrap(); + assert_eq!(doc.title.as_deref(), Some("My Custom Title")); + assert_eq!(doc.description.as_deref(), Some("My custom description")); + } + + #[test] + fn test_spec_under_namespaces_resources() { + let spec = r#" +openapi: "3.0.0" +info: + title: "Billing API" + version: "1.0" +servers: + - url: "https://billing.example.com" +paths: + /invoices: + get: + x-fern-sdk-group-name: ["invoices"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec_under("billing", spec); + let doc = app.build_doc().unwrap(); + assert!(doc.resources.contains_key("billing")); + let billing = doc.resources.get("billing").unwrap(); + assert!(billing.resources.contains_key("invoices")); + } + + #[test] + fn test_security_schemes_merge_across_multi_spec() { + // When two specs each declare `components.securitySchemes`, the + // merged doc should contain the union. Without merging, the second + // spec's schemes would silently disappear and the eventual + // RoutingAuthProvider registry would be missing entries — operations + // referencing those schemes would fall through to passthrough. + let spec_a = r#" +openapi: "3.0.0" +info: { title: A, version: "1.0" } +servers: [{ url: "https://a.example.com" }] +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer } +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: B, version: "1.0" } +servers: [{ url: "https://b.example.com" }] +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Api-Key } +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("multi").spec(spec_a).spec(spec_b).build_doc().unwrap(); + // Both schemes from both specs survive the merge. + assert!( + doc.security_schemes.contains_key("bearerAuth"), + "spec A's scheme missing: {:?}", + doc.security_schemes, + ); + assert!( + doc.security_schemes.contains_key("apiKey"), + "spec B's scheme missing: {:?}", + doc.security_schemes, + ); + } + + #[test] + fn test_merge_security_schemes_first_write_wins() { + use crate::openapi::discovery::SecurityScheme; + let mut acc = HashMap::new(); + acc.insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); + let mut incoming = HashMap::new(); + // Same name, different shape — first write wins, like merge_schemas. + incoming.insert( + "bearerAuth".to_string(), + SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + ); + merge_security_schemes(&mut acc, incoming); + assert_eq!(acc["bearerAuth"], SecurityScheme::HttpBearer); + } + + #[test] + fn test_merge_schemas_first_write_wins_on_duplicate() { + // Multi-spec setups commonly share schema names (`ErrorResponse`, + // `Pagination`). Strict-error policy made multi-spec use + // unworkable; first-write-wins lets specs share without manual + // de-duplication. + let mut acc = HashMap::new(); + acc.insert( + "ErrorResponse".to_string(), + crate::openapi::discovery::JsonSchema { + description: Some("first".to_string()), + ..Default::default() + }, + ); + let mut incoming = HashMap::new(); + incoming.insert( + "ErrorResponse".to_string(), + crate::openapi::discovery::JsonSchema { + description: Some("second".to_string()), + ..Default::default() + }, + ); + merge_schemas(&mut acc, incoming).expect("collision should not error"); + assert_eq!( + acc["ErrorResponse"].description.as_deref(), + Some("first"), + "first write should win" + ); + } + + #[test] + fn test_specs_under_batch_helper() { + // specs_under accepts an iterator of yamls and registers each under + // the same prefix. Sanity check it actually wires through. + let s1 = r#" +openapi: "3.0.0" +info: { title: A, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let s2 = r#" +openapi: "3.0.0" +info: { title: B, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("t").specs_under("ns", [s1, s2]); + let doc = app.build_doc().unwrap(); + let ns = &doc.resources["ns"]; + assert!(ns.resources.contains_key("alpha")); + assert!(ns.resources.contains_key("beta")); + } + + #[test] + fn test_spec_under_accepts_slash_delimited_path() { + // Slash splits into nested namespaces equivalent to specs_under_named. + let spec = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /widgets: + get: + x-fern-sdk-group-name: ["widgets"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("t") + .spec_under("v3/products", spec) + .build_doc() + .unwrap(); + let v3 = doc.resources.get("v3").expect("v3 namespace"); + let products = v3.resources.get("products").expect("nested products"); + assert!(products.resources.contains_key("widgets")); + } + + #[test] + fn test_spec_under_merges_multiple_specs_into_same_prefix() { + // Two specs sharing a prefix should merge under it (not error). + // Prevents use cases where many v2 specs all need + // to live under a single `v2` namespace. + let spec_a = r#" +openapi: "3.0.0" +info: { title: "A", version: "1.0" } +servers: [{ url: "https://a.example.com" }] +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "B", version: "1.0" } +servers: [{ url: "https://b.example.com" }] +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("test") + .spec_under("v2", spec_a) + .spec_under("v2", spec_b); + let doc = app.build_doc().unwrap(); + let v2 = doc.resources.get("v2").expect("v2 prefix should exist"); + assert!(v2.resources.contains_key("alpha")); + assert!(v2.resources.contains_key("beta")); + } + + #[test] + fn test_spec_under_collides_on_inner_resource() { + // Two specs with the same inner resource under the same prefix collide. + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: [{ url: "https://x.example.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let err = CliApp::new("test") + .spec_under("v2", spec) + .spec_under("v2", spec) + .build_doc() + .expect_err("inner-key collision should error"); + assert!(err.to_string().contains("things"), "error: {err}"); + } + + #[test] + fn test_spec_under_hoists_matching_top_level_resource() { + // When the namespace name matches a top-level resource in the spec, + // hoist that resource's methods into the namespace itself — so users + // type `customers get` instead of `customers customers get`. + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } + /customers/{id}/addresses: + get: + tags: [Addresses] + operationId: getAddresses + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("t").spec_under("customers", spec); + let doc = app.build_doc().unwrap(); + let customers = doc.resources.get("customers").expect("namespace exists"); + // Methods from the spec's `customers` resource hoisted into namespace. + assert!(customers.methods.contains_key("get-customers")); + // Sibling top-level resources (`addresses`) become children of the namespace. + assert!(customers.resources.contains_key("addresses")); + // No double-nested `customers.customers` from the hoist. + assert!(!customers.resources.contains_key("customers")); + } + + #[test] + fn test_specs_under_named_creates_nested_namespaces() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "A", version: "1.0" } +servers: [{ url: "https://a.example.com" }] +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "B", version: "1.0" } +servers: [{ url: "https://b.example.com" }] +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("t").specs_under_named( + "v3", + [("alpha", spec_a), ("beta", spec_b)], + ); + let doc = app.build_doc().unwrap(); + let v3 = doc.resources.get("v3").expect("v3 namespace"); + // Both specs nested under their own sub-namespace inside v3 (with hoist). + assert!(v3.resources.contains_key("alpha")); + assert!(v3.resources.contains_key("beta")); + let alpha = &v3.resources["alpha"]; + assert!(alpha.methods.contains_key("list")); + } + + #[test] + fn test_substitute_url_vars_replaces_known_and_leaves_unknown() { + let mut subs = HashMap::new(); + subs.insert("store_hash".to_string(), "abc123".to_string()); + let url = "https://api.example.com/stores/{store_hash}/v3/customers/{customer_id}"; + let out = substitute_url_vars(url, &subs); + // Known var substituted, unknown left literal so the failure mode is + // visible in dry-run output and downstream error messages. + assert_eq!( + out, + "https://api.example.com/stores/abc123/v3/customers/{customer_id}" + ); + } + + #[test] + fn test_apply_server_var_substitutions_walks_nested_resources() { + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: [{ url: "https://api.example.com/stores/{store_hash}/v3" }] +paths: + /a/{id}/b: + get: + x-fern-sdk-group-name: ["a", "b"] + x-fern-sdk-method-name: get + responses: { "200": { description: ok } } +"#; + let mut doc = CliApp::new("t").spec(spec).build_doc().unwrap(); + let mut subs = HashMap::new(); + subs.insert("store_hash".to_string(), "xyz".to_string()); + apply_server_var_substitutions(&mut doc, &subs); + + let nested_method = doc.resources["a"].resources["b"].methods.get("get").unwrap(); + assert_eq!(nested_method.root_url, "https://api.example.com/stores/xyz/v3"); + } + + #[test] + fn test_apply_server_var_substitutions_walks_named_servers() { + // Spec combines `{store_hash}` URL template variables with + // `x-fern-server-name` named servers. The substitution pass + // must rewrite the named-server URLs too — otherwise + // `resolve_named_server_url` reads back an unsubstituted URL + // and the executor sends the request to a literal + // `{store_hash}` host. + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: + - url: "https://api.example.com/stores/{store_hash}/v3" + x-fern-server-name: Production + - url: "https://staging.example.com/stores/{store_hash}/v3" + x-fern-server-name: Staging +paths: + /uploads: + post: + x-fern-sdk-group-name: ["uploads"] + x-fern-sdk-method-name: create + servers: + - url: "https://upload.example.com/stores/{store_hash}/v3" + x-fern-server-name: Upload + responses: { "200": { description: ok } } +"#; + let mut doc = CliApp::new("t").spec(spec).build_doc().unwrap(); + let mut subs = HashMap::new(); + subs.insert("store_hash".to_string(), "abc123".to_string()); + apply_server_var_substitutions(&mut doc, &subs); + + // Top-level named servers are substituted. + assert_eq!(doc.servers.len(), 2); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + assert_eq!(doc.servers[0].url, "https://api.example.com/stores/abc123/v3"); + assert_eq!(doc.servers[1].name.as_deref(), Some("Staging")); + assert_eq!(doc.servers[1].url, "https://staging.example.com/stores/abc123/v3"); + + // Per-operation `servers:` overrides are substituted too. + let create = doc.resources["uploads"].methods.get("create").unwrap(); + assert_eq!(create.servers.len(), 1); + assert_eq!(create.servers[0].name.as_deref(), Some("Upload")); + assert_eq!( + create.servers[0].url, + "https://upload.example.com/stores/abc123/v3", + ); + } + + #[test] + fn test_spec_under_root_url_on_methods() { + let spec = r#" +openapi: "3.0.0" +info: + title: "Billing API" + version: "1.0" +servers: + - url: "https://billing.example.com" +paths: + /invoices: + get: + x-fern-sdk-group-name: ["invoices"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec_under("billing", spec); + let doc = app.build_doc().unwrap(); + let billing = doc.resources.get("billing").unwrap(); + let invoices = billing.resources.get("invoices").unwrap(); + let list = invoices.methods.get("list").unwrap(); + assert_eq!(list.root_url, "https://billing.example.com"); + } + + #[test] + fn test_per_method_root_url_set_by_openapi_parser() { + let spec = r#" +openapi: "3.0.0" +info: + title: "API" + version: "1.0" +servers: + - url: "https://myapi.example.com" +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let doc = crate::openapi::load_openapi_spec(spec, "myapi").unwrap(); + let method = doc.resources["things"].methods["list"].clone(); + assert_eq!(method.root_url, "https://myapi.example.com"); + } + + #[test] + fn test_overlay_applied_before_parsing() { + let spec = r#" +openapi: "3.0.0" +info: + title: Plant API + version: "1.0" +servers: + - url: https://api.plants.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: Add description + version: "1.0" +actions: + - target: "$.info" + update: + description: "A plant management API" +"#; + let app = CliApp::new("plant-api").spec(spec).overlay(overlay); + let doc = app.build_doc().unwrap(); + assert_eq!(doc.description, Some("A plant management API".to_string())); + assert!(doc.resources.contains_key("plants")); + } + + #[test] + fn test_overlay_adds_fern_extensions() { + let spec = r#" +openapi: "3.0.0" +info: + title: Plant API + version: "1.0" +servers: + - url: https://api.plants.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + responses: + "200": + description: ok +"#; + // Overlay adds the fern extensions that were missing + let overlay = r#" +overlay: "1.0.0" +info: + title: Add fern extensions + version: "1.0" +actions: + - target: "$.paths['/plants'].get" + update: + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("plant-api").spec(spec).overlay(overlay); + let doc = app.build_doc().unwrap(); + assert!(doc.resources.contains_key("plants")); + assert!(doc.resources["plants"].methods.contains_key("list")); + } + + #[test] + fn test_multiple_overlays_on_same_spec() { + let spec = r#" +openapi: "3.0.0" +info: + title: Plant API + version: "1.0" +servers: + - url: https://api.plants.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let overlay1 = r#" +overlay: "1.0.0" +info: + title: Overlay 1 + version: "1.0" +actions: + - target: "$.info" + update: + description: "Plant API v1" +"#; + let overlay2 = r#" +overlay: "1.0.0" +info: + title: Overlay 2 + version: "1.0" +actions: + - target: "$.info" + update: + contact: + name: "Plant Store" +"#; + let app = CliApp::new("plant-api") + .spec(spec) + .overlay(overlay1) + .overlay(overlay2); + let doc = app.build_doc().unwrap(); + assert_eq!(doc.description, Some("Plant API v1".to_string())); + assert!(doc.resources.contains_key("plants")); + } + + // ----------------------------------------------------------------------- + // Overrides integration tests + // ----------------------------------------------------------------------- + + #[test] + fn test_spec_with_overrides_applies_fern_extensions() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /customers: + get: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + let customers = &doc.resources["customers"]; + assert!( + customers.methods.contains_key("list"), + "overrides should rename method to 'list', got: {:?}", + customers.methods.keys().collect::>() + ); + } + + #[test] + fn test_spec_under_with_overrides() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /items: + get: + tags: [Items] + operationId: getItems + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /items: + get: + x-fern-sdk-group-name: [items] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_under_with_overrides("v3", base, overrides); + let doc = app.build_doc().unwrap(); + let v3 = &doc.resources["v3"]; + let items = &v3.resources["items"]; + assert!( + items.methods.contains_key("list"), + "overrides under prefix should rename method to 'list'" + ); + } + + #[test] + fn test_specs_under_named_with_overrides() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /orders: + get: + tags: [Orders] + operationId: getOrders + responses: { "200": { description: ok } } +"#; + let overrides_a = r#" +paths: + /orders: + get: + x-fern-sdk-group-name: [orders] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test") + .specs_under_named_with_overrides("v3", [("orders", spec_a, overrides_a)]); + let doc = app.build_doc().unwrap(); + let v3 = &doc.resources["v3"]; + // merge_into_path hoists: prefix "v3/orders" + group-name "orders" → v3 > orders > list + let orders = &v3.resources["orders"]; + assert!( + orders.methods.contains_key("list"), + "named overrides should rename method to 'list', got: {:?}", + orders.methods.keys().collect::>() + ); + } + + #[test] + fn test_spec_without_overrides_unchanged() { + // Verify that `.spec()` (no overrides) still works identically. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /pets: + get: + tags: [Pets] + operationId: getPets + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("test").spec(yaml); + let doc = app.build_doc().unwrap(); + let pets = &doc.resources["pets"]; + assert!( + pets.methods.contains_key("get-pets"), + "without overrides, method name should come from operationId" + ); + } + + #[test] + fn test_overrides_null_removes_field() { + let base = r#" +openapi: "3.0.0" +info: + title: T + version: "1.0" + description: "Remove me" +servers: [{ url: "https://api.example.com" }] +paths: + /items: + get: + tags: [Items] + operationId: listItems + summary: "Original summary" + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /items: + get: + summary: null + x-fern-sdk-group-name: [items] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + let items = &doc.resources["items"]; + assert!( + items.methods.contains_key("list"), + "overrides should rename method even when combined with null deletions" + ); + } + + /// Array-of-objects merge via overrides: servers array elements are merged + /// by index (Fern parity), so the override can patch just one field. + #[test] + fn test_overrides_array_of_objects_merged_by_index() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + description: Production + - url: "https://staging.example.com" + description: Staging +paths: + /items: + get: + tags: [Items] + operationId: listItems + responses: { "200": { description: ok } } +"#; + let overrides = r#" +servers: + - url: "https://api-patched.example.com" +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + // Server[0] should be merged (url patched, description preserved) + assert_eq!(doc.root_url, "https://api-patched.example.com"); + } + + /// Primitive array replacement via overrides: tags are primitives so the + /// override replaces rather than merging by index. + #[test] + fn test_overrides_primitive_array_replaced() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /items: + get: + tags: [OldTag] + operationId: listItems + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /items: + get: + tags: [NewTag] + x-fern-sdk-group-name: [items] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + let items = &doc.resources["items"]; + assert!( + items.methods.contains_key("list"), + "overrides with replaced tags should still apply fern extensions" + ); + } + + /// Sequential overrides: two overrides applied in order. + #[test] + fn test_sequential_overrides_chain() { + use crate::openapi::parser::deep_merge_yaml; + + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /a: + get: + tags: [A] + operationId: getA + responses: { "200": { description: ok } } + /b: + get: + tags: [B] + operationId: getB + responses: { "200": { description: ok } } +"#; + let ovr1 = r#" +paths: + /a: + get: + x-fern-sdk-group-name: [alpha] + x-fern-sdk-method-name: list +"#; + let ovr2 = r#" +paths: + /b: + get: + x-fern-sdk-group-name: [beta] + x-fern-sdk-method-name: list +"#; + let base_val: serde_yaml::Value = serde_yaml::from_str(base).unwrap(); + let ovr1_val: serde_yaml::Value = serde_yaml::from_str(ovr1).unwrap(); + let ovr2_val: serde_yaml::Value = serde_yaml::from_str(ovr2).unwrap(); + let merged = deep_merge_yaml(deep_merge_yaml(base_val, ovr1_val), ovr2_val); + let doc = crate::openapi::parser::load_openapi_spec_from_value(merged, "t").unwrap(); + assert!(doc.resources["alpha"].methods.contains_key("list")); + assert!(doc.resources["beta"].methods.contains_key("list")); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/binding.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`OpenApiBinding`] — adapts [`super::CliApp`] to the root +//! [`crate::binding::Binding`] trait so it can be composed into +//! a root-level [`crate::app::CliApp`]. + +use std::sync::Arc; + +use crate::auth::{AuthCredentialSource, DynAuthProvider}; +use crate::binding::{Binding, BoxFuture, DispatchResult}; +use crate::error::CliError; +use crate::openapi::commands; +use crate::openapi::discovery::RestDescription; +use crate::openapi::executor; + +/// Prepared state computed once in `build_command()` and reused in +/// `dispatch()`. This avoids parsing the spec twice. +struct Prepared { + doc: RestDescription, + http_config: crate::http::HttpConfig, + auth_provider: DynAuthProvider, +} + +/// An OpenAPI binding that wraps [`super::CliApp`]'s internals and +/// exposes them through the [`Binding`] trait. +/// +/// ```rust,ignore +/// use fern_cli_sdk::app::CliApp; +/// use fern_cli_sdk::openapi::OpenApiBinding; +/// +/// fn main() { +/// CliApp::new("my-cli") +/// .binding( +/// OpenApiBinding::new() +/// .spec(include_str!("openapi.yaml")) +/// .auth_scheme_env("bearer", "MY_API_KEY"), +/// ) +/// .run() +/// } +/// ``` +#[must_use] +pub struct OpenApiBinding { + inner: super::CliApp, + /// Lazily computed on first `build_command()`, then reused in + /// `dispatch()`. `Arc` so we can clone it out of the lock without + /// holding across await. + prepared: std::sync::Mutex>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(None), + } + } +} + +impl OpenApiBinding { + /// Create a new OpenAPI binding. The CLI name is set automatically + /// by `CliApp::binding()` — no need to pass it here. + pub fn new() -> Self { + Self::default() + } + + /// Set the OpenAPI spec YAML string. + pub fn spec(mut self, yaml: &str) -> Self { + self.inner = self.inner.spec(yaml); + self + } + + /// Set a spec YAML with Fern-style overrides. + pub fn spec_with_overrides(mut self, yaml: &str, overrides: &str) -> Self { + self.inner = self.inner.spec_with_overrides(yaml, overrides); + self + } + + /// Set a spec under a prefix path. + pub fn spec_under(mut self, prefix: &str, yaml: &str) -> Self { + self.inner = self.inner.spec_under(prefix, yaml); + self + } + + /// Set multiple specs under a prefix. + pub fn specs_under(mut self, prefix: &str, yamls: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.inner = self.inner.specs_under(prefix, yamls); + self + } + + /// Bind a credential source to a named auth scheme (env var shorthand). + pub fn auth_scheme_env(mut self, scheme_name: &str, env_var: &str) -> Self { + self.inner = self.inner.auth_scheme_env(scheme_name, env_var); + self + } + + /// Bind a credential source to a named auth scheme. + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.inner = self.inner.auth_scheme(scheme_name, source); + self + } + + /// Add multiple specs under `prefix`, each in its own sub-namespace. + pub fn specs_under_named(mut self, prefix: &str, named: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + self.inner = self.inner.specs_under_named(prefix, named); + self + } + + /// Bind a custom auth provider to a named scheme. + pub fn auth_provider( + mut self, + scheme_name: &str, + provider: impl crate::auth::provider::AuthProvider + 'static, + ) -> Self { + self.inner = self.inner.auth_provider(scheme_name, provider); + self + } + + /// Bind a pre-built shared auth provider to a named scheme. + pub fn auth_provider_shared( + mut self, + scheme_name: &str, + provider: crate::auth::DynAuthProvider, + ) -> Self { + self.inner = self.inner.auth_provider_shared(scheme_name, provider); + self + } + + /// Bind HTTP Basic auth for the named scheme. + pub fn auth_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + self + } + + /// Register a server variable for URL template substitution. + pub fn server_var( + mut self, + name: &str, + env_var: Option<&str>, + default: Option<&str>, + description: Option<&str>, + ) -> Self { + self.inner = self.inner.server_var(name, env_var, default, description); + self + } + + /// Apply an overlay. + pub fn overlay(mut self, overlay_yaml: &str) -> Self { + self.inner = self.inner.overlay(overlay_yaml); + self + } + + /// Set compile-time audiences. + pub fn audiences(mut self, audiences: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.inner = self.inner.audiences(audiences); + self + } + + /// Prepare the binding state (idempotent; only runs once). + /// Returns an `Arc` clone so the caller doesn't hold the lock. + fn ensure_prepared(&self) -> Result, CliError> { + let mut guard = self.prepared.lock().unwrap(); + if let Some(ref arc) = *guard { + return Ok(Arc::clone(arc)); + } + + let mut doc = self.inner.build_doc()?; + commands::filter_doc_by_audiences(&mut doc, &self.inner.audiences); + + let http_config = crate::http::HttpConfig::new(&self.inner.name)? + .with_parsed_root_certs( + self.inner.extra_root_certs.iter().cloned(), + self.inner.extra_root_certs_pem.iter().cloned(), + ); + let auth_provider = self.inner.build_auth_provider(&doc); + + let arc = Arc::new(Prepared { + doc, + http_config, + auth_provider, + }); + *guard = Some(Arc::clone(&arc)); + Ok(arc) + } + + /// Build a [`BindingEntry`](super::app::BindingEntry) from this + /// binding's prepared state and the current CLI matches. + fn build_binding_entry( + &self, + matches: &clap::ArgMatches, + ) -> Result { + let prepared = self.ensure_prepared()?; + let mut doc_owned; + let doc = if self.inner.server_vars.is_empty() { + &prepared.doc + } else { + doc_owned = prepared.doc.clone(); + self.inner.apply_server_vars(&mut doc_owned, matches); + &doc_owned + }; + + // Finalize CLI-arg-bound auth sources against parsed matches, + // mirroring dispatch() so custom command handlers get working auth. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + let auth_provider = if cli_auth_args.is_empty() { + prepared.auth_provider.clone() + } else { + let matches_arc = std::sync::Arc::new(matches.clone()); + let finalized = crate::auth::finalize_bindings( + self.inner.auth_bindings.clone(), + &matches_arc, + ); + self.inner.build_auth_provider_from_finalized(&finalized, doc) + }; + + let global_headers: Vec<(String, String)> = doc + .global_headers + .iter() + .filter_map(|h| { + let val = super::app::resolve_global_header_value(matches, h)?; + Some((h.header.clone(), val)) + }) + .collect(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// Wrap a typed handler function into a [`CliCommandHandler`] that + /// automatically downcasts the binding context to + /// [`AppContext`](super::AppContext). + /// + /// Use this with [`CliApp::command()`](crate::app::CliApp::command) + /// or [`CliApp::command_under()`](crate::app::CliApp::command_under): + /// + /// ```rust,ignore + /// CliApp::new("my-cli") + /// .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) + /// .command(my_cmd(), OpenApiBinding::handler(my_handler)) + /// .run() + /// ``` + pub fn handler( + f: fn(&clap::ArgMatches, &super::AppContext) -> Result<(), crate::error::CliError>, + ) -> crate::app::CliCommandHandler { + Box::new(move |matches: &clap::ArgMatches, ctx: &dyn std::any::Any| { + let ctx = ctx.downcast_ref::().ok_or_else(|| { + crate::error::CliError::Validation( + "handler requires an OpenAPI binding context".into(), + ) + })?; + f(matches, ctx) + }) + } +} + +impl Binding for OpenApiBinding { + fn name(&self) -> &str { + &self.inner.name + } + + fn set_cli_name(&mut self, name: &str) { + self.inner.name = name.to_string(); + } + + fn set_root_auth(&mut self, bindings: &[(String, crate::auth::SchemeBinding)]) { + // Root-level auth bindings are prepended to the inner CliApp's + // auth_bindings. If the binding also has its own auth_scheme_env() + // calls, those take priority (they appear later and override). + let mut merged = bindings.to_vec(); + merged.extend(std::mem::take(&mut self.inner.auth_bindings)); + self.inner.auth_bindings = merged; + } + + fn validate_auth(&self) -> Result<(), CliError> { + // Only validate when root-level auth is being used (auth_bindings + // is non-empty). If the binding has no auth bindings at all, it's + // intentionally running unauthenticated — no validation needed. + if self.inner.auth_bindings.is_empty() { + return Ok(()); + } + let prepared = self.ensure_prepared()?; + let registered: std::collections::HashSet<&str> = self + .inner + .auth_bindings + .iter() + .map(|(name, _)| name.as_str()) + .collect(); + let mut missing: Vec<&str> = Vec::new(); + for scheme_name in prepared.doc.security_schemes.keys() { + if !registered.contains(scheme_name.as_str()) { + missing.push(scheme_name.as_str()); + } + } + if !missing.is_empty() { + missing.sort(); + // Warn rather than fail — multi-spec binaries may intentionally + // bind only a subset of schemes (e.g. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + fn build_command(&self) -> Result { + let prepared = self.ensure_prepared()?; + let cli = commands::build_cli(&prepared.doc) + .subcommand(crate::openapi::skill_emitter::generate_skills_command()); + let mut cli = self.inner.decorate_command(&prepared.doc, cli); + + // Register global -- flags for CLI-bound auth sources + // so clap knows about them before parsing. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + for arg_name in &cli_auth_args { + let kebab = arg_name.replace('_', "-"); + cli = cli.arg( + clap::Arg::new(arg_name.clone()) + .long(kebab) + .global(true) + .value_name(arg_name.to_uppercase()) + .help("Auth credential"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + fn dispatch<'a>( + &'a self, + root_matches: &'a clap::ArgMatches, + _sub_matches: &'a clap::ArgMatches, + _op_path: &'a [String], + ) -> BoxFuture<'a, Result> { + // Clone the Arc so we don't hold the lock across the await. + let prepared = match self.ensure_prepared() { + Ok(p) => p, + Err(e) => return Box::pin(async move { Err(e) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _op_path == ["generate-skills"] { + let output_dir = _sub_matches.get_one::("output-dir"); + let result = self.inner.handle_generate_skills( + output_dir.map(|s| s.as_str()), + &prepared.doc, + ); + return Box::pin(async move { + result?; + Ok(DispatchResult::Handled) + }); + } + + Box::pin(async move { + // If any auth source uses CLI flags, finalize them against + // the parsed matches and rebuild the auth provider. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + let auth_provider = if cli_auth_args.is_empty() { + prepared.auth_provider.clone() + } else { + let matches_arc = std::sync::Arc::new(root_matches.clone()); + let finalized = crate::auth::finalize_bindings( + self.inner.auth_bindings.clone(), + &matches_arc, + ); + self.inner.build_auth_provider_from_finalized(&finalized, &prepared.doc) + }; + + // Apply server-variable substitutions to a local copy of the doc + // if any server vars are registered. + let mut doc_owned; + let doc = if self.inner.server_vars.is_empty() { + &prepared.doc + } else { + doc_owned = prepared.doc.clone(); + self.inner.apply_server_vars(&mut doc_owned, root_matches); + &doc_owned + }; + + // Walk the subcommand tree from root to find the target method. + let (method, matched_args) = + super::resolve_method_from_matches(doc, root_matches)?; + + let params_override = matched_args + .get_one::("params") + .map(|s| s.as_str()); + let params = super::app::collect_params_from_flags( + matched_args, + method, + params_override, + )?; + let params_json_string = serde_json::to_string(¶ms) + .map_err(|e| CliError::Validation(format!("Failed to serialize params: {e}")))?; + let params_json: Option<&str> = if params.is_empty() { + None + } else { + Some(¶ms_json_string) + }; + + let body_json_owned = crate::cli_args::resolve_body_json(matched_args)?; + let body_json = body_json_owned.as_deref(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + let no_extract = matched_args.get_flag("no-extract"); + let no_retry = matched_args.get_flag("no-retry"); + let no_stream = matched_args + .try_get_one::("no-stream") + .ok() + .flatten() + .copied() + .unwrap_or(false); + + let binary_body_path = method + .binary_request_body + .as_ref() + .and_then(|b| { + matched_args + .try_get_one::(&b.flag_name) + .ok() + .flatten() + .map(|s| s.as_str()) + }); + + // Validate binary body path for dangerous characters. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_overrides( + matched_args, + doc, + method, + ¶ms, + )?; + + // --base-url flag wins; otherwise {NAME}_BASE_URL env var. + let base_url_override_owned = + crate::cli_args::resolve_base_url_override(root_matches, &self.inner.name)?; + let base_url_override = base_url_override_owned.as_deref(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_overrides, + ) + .await?; + + match result { + Some(value) => Ok(DispatchResult::Value(value)), + None => Ok(DispatchResult::Handled), + } + }) + } + + fn binding_context( + &self, + matches: &clap::ArgMatches, + ) -> Result>, CliError> { + let entry = self.build_binding_entry(matches)?; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + + fn merge_binding_context( + &self, + matches: &clap::ArgMatches, + existing: Option>, + ) -> Result>, CliError> { + let entry = self.build_binding_entry(matches)?; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + // Different binding type — start a new AppContext, + // discard the incompatible context. + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/commands.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/commands.rs new file mode 100644 index 000000000000..c5d3897cc368 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/commands.rs @@ -0,0 +1,1563 @@ +//! CLI Command Builder +//! +//! Builds a dynamic `clap::Command` tree from the internal API representation. + +use clap::builder::{PossibleValue, PossibleValuesParser}; +use clap::{Arg, Command}; + +use std::collections::HashMap; + +use crate::openapi::discovery::{ + Availability, FernEnumValue, MethodParameter, RestDescription, RestResource, SdkGroupInfo, +}; +use crate::text::to_kebab_flag; + +/// Filter the document in-place so only operations matching at least +/// one of `active_audiences` survive into the command tree. Mirrors +/// fern-api/fern's OpenAPI importer behavior in +/// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`: +/// +/// ```text +/// if (audiences.length > 0 +/// && !audiences.some(a => operationAudiences.includes(a))) { +/// continue; +/// } +/// ``` +/// +/// Semantics, mirroring fern verbatim: +/// +/// 1. **No active audience** (`active_audiences` is empty) — every +/// operation survives regardless of its tags. +/// 2. **One or more active audiences** — an operation is kept only if +/// its `audiences` set intersects `active_audiences` (set OR, not +/// AND). Operations with empty `audiences` are dropped, since +/// `[].some(...)` is always false. +/// 3. **Untagged operations are NOT included** when a filter is active — +/// deliberate fern parity (a "no audience" tag is treated as "belongs +/// to no audience", not "belongs to all audiences"). +/// +/// After the per-operation prune, empty resource groups (no methods, +/// no non-empty children) are collapsed so they don't surface as bare +/// subcommands with no leaves — same approach used by the +/// `x-fern-ignore` pass in `parser.rs::prune_empty_resources`. +pub fn filter_doc_by_audiences(doc: &mut RestDescription, active_audiences: &[String]) { + if active_audiences.is_empty() { + return; + } + filter_resources_by_audiences(&mut doc.resources, active_audiences); +} + +/// Recursive worker for [`filter_doc_by_audiences`]. Drops methods that +/// don't intersect `active`, then recurses into nested resources, then +/// finally prunes resources that ended up empty. +fn filter_resources_by_audiences( + resources: &mut std::collections::HashMap, + active: &[String], +) { + resources.retain(|_, resource| { + resource + .methods + .retain(|_, method| method_matches_audiences(&method.audiences, active)); + filter_resources_by_audiences(&mut resource.resources, active); + !resource.methods.is_empty() || !resource.resources.is_empty() + }); +} + +/// Membership check mirroring fern's +/// `audiences.some(a => operationAudiences.includes(a))`. The names are +/// compared as opaque strings (case-sensitive, no normalization) so a +/// preset `audiences(["Public"])` and an operation tagged +/// `x-fern-audiences: [public]` deliberately do NOT match — matching +/// how the upstream importer treats audience names as identifiers. +fn method_matches_audiences(method_audiences: &[String], active: &[String]) -> bool { + active.iter().any(|a| method_audiences.iter().any(|m| m == a)) +} + +/// Prepends the availability badge (e.g. `[BETA] `) to `text` when one is +/// present. Falls back to `text` unchanged for generally-available items +/// and items with no availability marker. +fn with_availability_badge(text: &str, availability: Option) -> String { + match availability.and_then(Availability::badge) { + Some(badge) if text.is_empty() => badge.to_string(), + Some(badge) => format!("{badge} {text}"), + None => text.to_string(), + } +} + +/// Names of built-in flags that must not be duplicated by parameter-derived flags. +pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ + "params", + "output", + "json", + "format", + "dry-run", + "base-url", + "page-all", + "page-limit", + "page-delay", + "no-extract", + "no-retry", + "no-stream", + "quiet", + "help", +]; + +/// The non-auth portion of the `--help` footer. Auth env vars are +/// computed dynamically from bindings by `CliApp::run_async` and +/// prepended via `Command::after_help` — keeping them out of this string +/// avoids stale `{NAME}_API_KEY` boilerplate. +pub fn after_help_footer(binary_name: &str) -> String { + let prefix = binary_name.to_uppercase().replace('-', "_"); + format!( + "Environment variables:\n \ + {prefix}_BASE_URL Override the API base URL\n \ + {prefix}_CA_BUNDLE Path to PEM file with extra trust roots (or SSL_CERT_FILE)\n \ + {prefix}_INSECURE=1 Skip TLS verification (debugging only)\n \ + {prefix}_PROXY HTTP(S) proxy URL\n \ + {prefix}_TIMEOUT_SECS Total request timeout\n\n\ + Standard env vars (HTTPS_PROXY / HTTP_PROXY / NO_PROXY / SSL_CERT_FILE) are also honored." + ) +} + +/// Builds the full CLI command tree from an API description. +pub fn build_cli(doc: &RestDescription) -> Command { + let about_text = doc + .title + .clone() + .unwrap_or_else(|| format!("{} CLI", doc.name)); + let after_help = after_help_footer(&doc.name); + let mut root = Command::new(doc.name.clone()) + .about(about_text) + .after_help(after_help) + .term_width(200) + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + clap::Arg::new("dry-run") + .long("dry-run") + .help("Validate the request locally without sending it to the API") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + clap::Arg::new("format") + .long("format") + .help("Output format: json (default), table, yaml, csv") + .value_name("FORMAT") + .global(true), + ) + .arg( + clap::Arg::new("base-url") + .long("base-url") + .help("Override the API base URL (e.g. for testing against a mock server)") + .value_name("URL") + .global(true), + ) + .arg( + clap::Arg::new("quiet") + .long("quiet") + .short('q') + .help("Suppress stdout output on success (errors still go to stderr)") + .action(clap::ArgAction::SetTrue) + .global(true), + ); + + // Add resource subcommands + let mut resource_names: Vec<_> = doc.resources.keys().collect(); + resource_names.sort(); + for name in resource_names { + let resource = &doc.resources[name]; + if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + root = root.subcommand(cmd); + } + } + + root +} + +/// Resolve the `about()` line for a group's clap subcommand. Returns +/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from +/// the document-root `x-fern-groups` extension) when present; falls +/// back to the legacy `Operations on ''` label otherwise. The +/// fallback preserves the current default behavior unchanged for any +/// group identifier that doesn't appear in `x-fern-groups`. +pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { + groups + .get(name) + .and_then(|info| info.summary.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("Operations on '{name}'")) +} + +/// Resolve the `long_about()` line for a group's clap subcommand from +/// the document-root `x-fern-groups` extension's `description` field. +/// `None` when the group has no entry or the entry omits `description` +/// — clap then falls back to the `about()` text for `--help`. +pub(crate) fn group_long_about_text( + name: &str, + groups: &HashMap, +) -> Option { + groups + .get(name) + .and_then(|info| info.description.clone()) + .filter(|s| !s.is_empty()) +} + +/// Stringify a parameter's resolved client-side default value for +/// clap's `Arg::default_value`. Strings pass through verbatim; numbers +/// and booleans use their natural lexical form (e.g. `100`, `true`); +/// other JSON shapes (arrays, objects) fall through to compact JSON — +/// but in practice `x-fern-default` only carries scalar literals so the +/// scalar branch is the load-bearing case. +/// +/// Returns `None` for `Value::Null` and the `None` input so the caller +/// can skip setting any clap default. +pub(crate) fn default_value_for_clap(value: &Option) -> Option { + match value.as_ref()? { + serde_json::Value::Null => None, + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Bool(b) => Some(b.to_string()), + serde_json::Value::Number(n) => Some(n.to_string()), + other => Some(other.to_string()), + } +} + +/// Format an OpenAPI standard `default:` value as a trailing +/// documentation suffix to append to a flag's help text. Renders as +/// `[default: ]` so the user sees the same shape as clap's +/// auto-generated `[default: ...]` for `x-fern-default` — the help +/// surface intentionally does not distinguish client-side defaults +/// (sent on the wire) from server-side defaults (doc-only). The split +/// stays a wire-behavior concern, not a documentation concern. +/// +/// Returns the bare scalar rendering with a leading space so callers +/// can concatenate it directly onto `Arg::help`. +pub(crate) fn documentation_default_help_suffix( + value: &Option, +) -> Option { + let rendered = match value.as_ref()? { + serde_json::Value::Null => return None, + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + other => other.to_string(), + }; + Some(format!(" [default: {rendered}]")) +} + +/// Recursively builds a Command for a resource. +/// Returns None if the resource has no methods or sub-resources. +/// +/// `groups` carries the parsed document-root `x-fern-groups` block; when +/// a matching entry exists for `name` it overrides the `about()`/ +/// `long_about()` text rendered in `--help`. Unmatched resources retain +/// the legacy `Operations on ''` label and alphabetical placement +/// so adding `x-fern-groups` is strictly additive. +fn build_resource_command( + name: &str, + resource: &RestResource, + groups: &HashMap, +) -> Option { + let mut cmd = Command::new(name.to_string()) + .about(group_about_text(name, groups)) + .subcommand_required(true) + .arg_required_else_help(true); + + if let Some(long_about) = group_long_about_text(name, groups) { + cmd = cmd.long_about(long_about); + } + + let mut has_children = false; + + // Add method subcommands + let mut method_names: Vec<_> = resource.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let method = &resource.methods[method_name]; + + has_children = true; + + let about = crate::text::truncate_description( + method.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + let about = with_availability_badge(&about, method.availability); + + let mut method_cmd = Command::new(method_name.to_string()) + .about(about) + .arg( + Arg::new("params") + .long("params") + .help("Additional parameters as JSON (overrides individual flags)") + .value_name("JSON"), + ) + .arg( + Arg::new("output") + .long("output") + .short('o') + .help("Output file path for binary responses") + .value_name("PATH"), + ); + + // Add --json flag for REST request bodies + if method.request.is_some() { + method_cmd = method_cmd.arg( + Arg::new("json") + .long("json") + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), + ); + } + + // Add a typed flag for operations with a binary request body + // (e.g. application/octet-stream). The file is streamed as the body + // with the content type declared in the spec. The flag name comes from + // `x-fern-parameter-name` on the requestBody, or defaults to `file` + // for `format: binary` schemas (else `body`). + // + // Accepts three forms: , @ (curl-style), or `-` for stdin. + if let Some(ref binary) = method.binary_request_body { + method_cmd = method_cmd.arg( + Arg::new(binary.flag_name.clone()) + .long(binary.flag_name.clone()) + .value_name("PATH|@PATH|-") + .help(format!( + "Body for the request (Content-Type: {}). Accepts:\n \ + plain filesystem path\n \ + @ same path (curl-style prefix)\n \ + - read from stdin (sent chunked)", + binary.content_type, + )), + ); + } + + // Pagination flags + method_cmd = method_cmd + .arg( + Arg::new("page-all") + .long("page-all") + .help("Auto-paginate through all results (NDJSON)") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("page-limit") + .long("page-limit") + .help("Maximum number of pages to fetch (default: 10)") + .value_name("N") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + Arg::new("page-delay") + .long("page-delay") + .help("Delay in milliseconds between page fetches (default: 100)") + .value_name("MS") + .value_parser(clap::value_parser!(u64)), + ) + .arg( + Arg::new("no-extract") + .long("no-extract") + .help( + "Disable x-fern-sdk-return-value extraction and print the full response body", + ) + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("no-retry") + .long("no-retry") + .help( + "Disable retries declared by x-fern-retries on this operation, \ + including network errors. Useful for debugging.", + ) + .action(clap::ArgAction::SetTrue), + ); + + // `--no-stream` is only meaningful on operations with + // `x-fern-streaming`. Registering it unconditionally would let + // clap accept it on unrelated ops and silently no-op, which + // hides spec/runtime mismatches; instead, expose it only where + // it does something so non-streaming siblings reject the flag + // up-front. + if method.streaming.is_some() { + method_cmd = method_cmd.arg( + Arg::new("no-stream") + .long("no-stream") + .help( + "Buffer the streaming response and print it as a single value once \ + complete (handy for piping into another JSON tool)", + ) + .action(clap::ArgAction::SetTrue), + ); + } + + // Generate individual flags from method parameters + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for param_name in param_names { + let param = &method.parameters[param_name]; + + // Flag name resolution: + // 1. `flag_name_override` (set verbatim, no kebab pass) — + // populated only by synthetic Fern-extension injections + // (currently `inject_idempotency_header_params`). See + // `MethodParameter::flag_name_override`. + // 2. `display_name` from `x-fern-parameter-name` — kebabed. + // Renames the CLI flag while keeping `param_name` (the + // wire name) as the clap arg ID. Downstream + // `collect_params_from_flags` looks values up by arg ID, + // and the executor uses the params map key (= wire name) + // when serializing the request, so the alias never leaks + // onto the wire — only the user-facing flag changes. + // Mirrors fern's openapi-ir-parser, which renames the + // SDK parameter via `parameterNameOverride` while + // preserving the OpenAPI parameter's `name` on the HTTP + // request. + // 3. Fallback: kebab the HashMap key. + let kebab_name = if let Some(override_flag) = param.flag_name_override.as_deref() { + override_flag.to_string() + } else { + let flag_source = param.display_name.as_deref().unwrap_or(param_name.as_str()); + to_kebab_flag(flag_source) + }; + if BUILTIN_FLAG_NAMES.contains(&kebab_name.as_str()) { + continue; + } + + // Variable-bound path parameters get their value from a + // root-level global flag (registered in `app::run_async` from + // `doc.sdk_variables`) plus its env-var fallback. Skipping + // here keeps the per-operation flag surface clean and matches + // Fern's openapi-ir-parser, which lowers these into + // constructor-style globals rather than method arguments. + if param.variable_reference.is_some() { + continue; + } + + let value_name = match param.param_type.as_deref() { + Some("string") => "STRING", + Some("integer") => "NUMBER", + Some("number") => "NUMBER", + Some("boolean") => "BOOLEAN", + Some("array") => "JSON_ARRAY", + Some("object") => "JSON_OBJECT", + _ => "VALUE", + }; + + let help_text = crate::text::truncate_description( + param.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + let help_text = with_availability_badge(&help_text, param.availability); + // When the flag has been renamed via `x-fern-parameter-name`, + // surface the original wire name in `--help` so users can + // still correlate the flag with the API doc / `--params` JSON. + // (Synthetic `flag_name_override` injections already encode + // the wire name in their description, so they skip this.) + let help_text = match param.display_name.as_deref() { + Some(alias) if param.flag_name_override.is_none() && alias != param_name => { + if help_text.is_empty() { + format!("(wire name: {param_name})") + } else { + format!("{help_text} (wire name: {param_name})") + } + } + _ => help_text, + }; + // Append the OpenAPI standard `default:` value as a + // `[default: ...]` suffix when it is the only default + // source. Same visual shape as clap's auto-rendered + // `[default: ...]` for `x-fern-default` — the user sees + // "there is a default" without being told whether the CLI + // or the server applies it. The CLI itself does not send + // this value on the wire (only `x-fern-default` populates + // `default_value` below). + let help_text = match documentation_default_help_suffix( + ¶m.documentation_default_value, + ) { + Some(suffix) => format!("{help_text}{suffix}"), + None => help_text, + }; + + let mut arg = Arg::new(param_name.clone()) + .long(kebab_name) + .value_name(value_name) + .help(help_text); + + // Only `x-fern-default` (lowered into `default_value`) + // becomes a clap default. The standard `default:` keyword + // is doc-only and handled above via the help-text suffix. + if let Some(default_str) = default_value_for_clap(¶m.default_value) { + arg = arg.default_value(default_str); + } + + // Environment-variable fallback (currently populated by the + // OpenAPI parser for synthetic idempotency-header params from + // `x-fern-idempotency-headers`, with overrides applied by + // `CliApp::idempotency_header_env`). Clap reads `.env(...)` + // when the flag is absent on the command line, giving us the + // same priority order — flag → env → default — used for auth + // sources. + if let Some(ref env_var) = param.env_var { + arg = arg.env(env_var.clone()); + } + + if let Some(ref enum_values) = param.enum_values { + arg = arg.value_parser(build_enum_value_parser(enum_values, param)); + } + + if param.repeated { + arg = arg.action(clap::ArgAction::Append); + } + + method_cmd = method_cmd.arg(arg); + } + + cmd = cmd.subcommand(method_cmd); + } + + // Add sub-resource subcommands (recursive) + let mut sub_names: Vec<_> = resource.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let sub_resource = &resource.resources[sub_name]; + if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + has_children = true; + cmd = cmd.subcommand(sub_cmd); + } + } + + if has_children { + Some(cmd) + } else { + None + } +} + +/// Build a `PossibleValuesParser` that respects an optional `x-fern-enum` +/// override. When the parameter has no `fern_enum` map, this is a plain +/// `PossibleValuesParser::new(wire_values)`. When it does, each wire value +/// gets an alias + per-value help string so `--help` renders the display +/// name and description while either the display name or wire value parses +/// successfully on the command line. +fn build_enum_value_parser( + wire_values: &[String], + param: &MethodParameter, +) -> PossibleValuesParser { + let possible: Vec = wire_values + .iter() + .map(|wire| { + let cfg = param + .fern_enum + .as_ref() + .and_then(|m| m.get(wire)); + build_possible_value(wire, cfg) + }) + .collect(); + PossibleValuesParser::from(possible) +} + +/// Construct a single `PossibleValue` from a wire value and its optional +/// `x-fern-enum` config. The display name (if set and different from the +/// wire value) becomes the canonical rendered name, with the wire value +/// as a parse-time alias. Descriptions surface as long-help text. +fn build_possible_value(wire: &str, cfg: Option<&FernEnumValue>) -> PossibleValue { + let display = cfg.and_then(|c| c.display_name.as_deref()); + let mut pv = match display { + Some(name) if name != wire => PossibleValue::new(name.to_string()).alias(wire.to_string()), + _ => PossibleValue::new(wire.to_string()), + }; + if let Some(desc) = cfg.and_then(|c| c.description.as_deref()) { + pv = pv.help(desc.to_string()); + } + pv +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openapi::discovery::{FernEnumValue, MethodParameter, RestMethod, RestResource}; + use std::collections::HashMap; + + fn make_doc() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "list".to_string(), + ..Default::default() + }, + ); + methods.insert( + "delete".to_string(), + RestMethod { + http_method: "DELETE".to_string(), + path: "delete".to_string(), + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "files".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_all_commands_always_shown() { + let doc = make_doc(); + let cmd = build_cli(&doc); + + let files_cmd = cmd + .find_subcommand("files") + .expect("files resource missing"); + + assert!(files_cmd.find_subcommand("list").is_some()); + assert!(files_cmd.find_subcommand("delete").is_some()); + } + + #[test] + fn test_root_uses_doc_name() { + let doc = make_doc(); + let cmd = build_cli(&doc); + assert_eq!(cmd.get_name(), "test-cli"); + } + + #[test] + fn test_method_params_become_flags() { + let mut params = HashMap::new(); + params.insert( + "uuid".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user UUID".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + params.insert( + "status".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by status".to_string()), + location: Some("query".to_string()), + enum_values: Some(vec!["active".to_string(), "inactive".to_string()]), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "get-user".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/users/{uuid}".to_string(), + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let users_cmd = cmd.find_subcommand("users").expect("users resource missing"); + let get_user_cmd = users_cmd + .find_subcommand("get-user") + .expect("get-user method missing"); + + // Verify individual flags exist + let args: Vec = get_user_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!(args.contains(&"uuid".to_string()), "uuid flag missing"); + assert!(args.contains(&"status".to_string()), "status flag missing"); + assert!(args.contains(&"params".to_string()), "params flag missing"); + } + + #[test] + fn test_variable_bound_param_skipped_from_per_op_flags() { + // Path parameters that carry `x-fern-sdk-variable` must NOT appear + // as per-operation flags. Their value comes from a root-level + // global flag registered in `app::run_async` from + // `doc.sdk_variables` (with env-var fallback). Mirrors Fern's + // openapi-ir-parser semantics: variables are constructor-style + // globals, not per-method arguments. + let mut params = HashMap::new(); + params.insert( + "gardenId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Garden tenant".to_string()), + location: Some("path".to_string()), + required: true, + variable_reference: Some("gardenId".to_string()), + ..Default::default() + }, + ); + // A plain (non-variable-bound) path param on the same op still + // surfaces as a per-op flag. + params.insert( + "zoneId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Zone id".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/gardens/{gardenId}/zones/{zoneId}".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "zones".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "garden-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let zones_cmd = cmd + .find_subcommand("zones") + .expect("zones resource missing"); + let get_cmd = zones_cmd + .find_subcommand("get") + .expect("zones.get missing"); + let arg_ids: Vec = get_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!( + !arg_ids.contains(&"gardenId".to_string()), + "variable-bound path param should NOT be a per-op flag, got: {arg_ids:?}", + ); + assert!( + arg_ids.contains(&"zoneId".to_string()), + "plain path param should still surface as a per-op flag, got: {arg_ids:?}", + ); + } + + #[test] + fn test_builtin_flag_names_not_duplicated() { + let mut params = HashMap::new(); + params.insert( + "format".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Response format".to_string()), + ..Default::default() + }, + ); + params.insert( + "output".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Output type".to_string()), + ..Default::default() + }, + ); + params.insert( + "real_param".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("A real param".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "test-method".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/test".to_string(), + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + // This should not panic from duplicate arg names + let cmd = build_cli(&doc); + let things_cmd = cmd + .find_subcommand("things") + .expect("things resource missing"); + let test_cmd = things_cmd + .find_subcommand("test-method") + .expect("test-method missing"); + + let args: Vec = test_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + + // "format" and "output" should NOT appear as duplicated param flags + // but "real_param" should be present + assert!( + args.contains(&"real_param".to_string()), + "real_param flag missing" + ); + + // Count occurrences of "format" — should be at most 1 (from the global flag) + let format_count = args.iter().filter(|a| *a == "format").count(); + assert!( + format_count <= 1, + "format flag duplicated: found {format_count}" + ); + } + // ------------------------------------------------------------------ + // x-fern-enum → clap PossibleValue wiring + // + // These tests target `build_enum_value_parser` directly so the + // mapping between the `MethodParameter.fern_enum` map and clap's + // `PossibleValue` (canonical name + alias + help) can't drift. + // ------------------------------------------------------------------ + fn param_with_fern_enum( + wire_values: &[&str], + entries: &[(&str, Option<&str>, Option<&str>)], + ) -> MethodParameter { + let mut map = HashMap::new(); + for (wire, name, desc) in entries { + map.insert( + (*wire).to_string(), + FernEnumValue { + display_name: name.map(|s| s.to_string()), + description: desc.map(|s| s.to_string()), + }, + ); + } + MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(wire_values.iter().map(|s| s.to_string()).collect()), + fern_enum: Some(map), + ..Default::default() + } + } + + /// Drive `build_enum_value_parser` through a real `clap::Command` + /// `--help` render so the assertions cover what the user sees, not + /// just internals. Returns the lower-cased help text so substring + /// matches are case-insensitive. + fn render_arg_long_help(param: &MethodParameter) -> String { + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), param); + let cmd = Command::new("test").arg( + Arg::new("status") + .long("status") + .value_parser(parser) + .help("Filter by status"), + ); + let mut buf = Vec::new(); + cmd.clone() + .write_long_help(&mut buf) + .expect("clap should render long help"); + String::from_utf8(buf).expect("clap long help is utf-8") + } + + #[test] + fn test_build_enum_value_parser_no_fern_enum_uses_wire_values() { + let param = MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(vec!["a".to_string(), "b".to_string()]), + ..Default::default() + }; + let help = render_arg_long_help(¶m); + assert!( + help.contains("possible values") && help.contains("a") && help.contains("b"), + "wire values must be listed in long help when no fern_enum is set; got:\n{help}", + ); + } + + #[test] + fn test_build_enum_value_parser_renders_display_name_and_per_value_help() { + let param = param_with_fern_enum( + &["all", "managed", "external"], + &[ + ("all", Some("All"), Some("Every user.")), + ( + "managed", + Some("Managed"), + Some("Enterprise-managed users."), + ), + ("external", None, Some("External collaborators only.")), + ], + ); + let help = render_arg_long_help(¶m); + + // Display names are the rendered option labels in long help. + assert!( + help.contains("All") && help.contains("Managed"), + "display names must appear in long help, got:\n{help}", + ); + // The un-overridden entry still surfaces its wire value. + assert!( + help.contains("external"), + "wire value must appear when no display override is set, got:\n{help}", + ); + // Per-value descriptions land in long help. + assert!( + help.contains("Every user."), + "missing first description in:\n{help}" + ); + assert!( + help.contains("Enterprise-managed users."), + "missing second description in:\n{help}", + ); + assert!( + help.contains("External collaborators only."), + "missing third description in:\n{help}", + ); + } + + /// Both the display alias and the wire value must successfully parse + /// when `display_name` is set — this is the key affordance promised + /// by `x-fern-enum` for CLI users who only know one of the names. + #[test] + fn test_build_enum_value_parser_accepts_both_display_and_wire() { + let param = param_with_fern_enum( + &["all", "managed", "external"], + &[ + ("all", Some("All"), None), + ("managed", Some("Managed"), None), + ], + ); + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), ¶m); + let cmd = Command::new("test").arg(Arg::new("status").long("status").value_parser(parser)); + + for input in ["All", "all", "Managed", "managed", "external"] { + let matches = cmd + .clone() + .try_get_matches_from(vec!["test", "--status", input]) + .unwrap_or_else(|e| panic!("input `{input}` should parse; got error: {e}")); + let parsed = matches.get_one::("status").unwrap(); + assert_eq!( + parsed, input, + "clap returns the user-typed value verbatim; display->wire mapping happens later", + ); + } + + // A bogus value still fails — guards against accidentally + // accepting arbitrary strings when fern_enum is set. + assert!( + cmd.clone() + .try_get_matches_from(vec!["test", "--status", "Bogus"]) + .is_err(), + "values not in the enum must still be rejected", + ); + } + + /// `name == wire` is a no-op: clap rejects an alias equal to the + /// canonical name, so the builder must dedupe instead of crashing. + /// Build the parser into a `Command` to confirm clap accepts it. + #[test] + fn test_build_enum_value_parser_skips_alias_when_display_equals_wire() { + let param = param_with_fern_enum( + &["managed"], + &[("managed", Some("managed"), Some("Same wire & display."))], + ); + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), ¶m); + let cmd = Command::new("test").arg(Arg::new("status").long("status").value_parser(parser)); + let matches = cmd + .try_get_matches_from(vec!["test", "--status", "managed"]) + .expect("clap should accept a PossibleValue without a self-alias"); + assert_eq!(matches.get_one::("status").unwrap(), "managed"); + } + + #[test] + fn test_json_help_text_rest_method() { + use crate::openapi::discovery::SchemaRef; + + // REST method with a request body → --json should describe the request body. + let mut rest_methods = HashMap::new(); + rest_methods.insert( + "create".to_string(), + RestMethod { + http_method: "POST".to_string(), + path: "/things".to_string(), + request: Some(SchemaRef { + schema_ref: Some("Thing".to_string()), + parameter_name: None, + }), + ..Default::default() + }, + ); + let mut rest_resources = HashMap::new(); + rest_resources.insert( + "things".to_string(), + RestResource { + methods: rest_methods, + resources: HashMap::new(), + }, + ); + let rest_doc = RestDescription { + name: "rest-cli".to_string(), + resources: rest_resources, + ..Default::default() + }; + let rest_cmd = build_cli(&rest_doc); + let rest_json = rest_cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("create")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "json")) + .expect("REST --json arg missing"); + let rest_help = rest_json + .get_help() + .map(|s| s.to_string()) + .unwrap_or_default(); + assert!( + rest_help.contains("request body"), + "REST --json help should describe the request body, got: {rest_help}", + ); + } + + // ------------------------------------------------------------------ + // filter_doc_by_audiences — fern parity + // ------------------------------------------------------------------ + + /// Build a doc with four operations covering every audience-tag + /// shape used by the fixture spec: one tagged, one with a + /// distinct tag, one untagged, and one multi-tagged. Used by all + /// `filter_doc_by_audiences` tests below. + fn doc_with_audiences() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert( + "public-only".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/p".to_string(), + audiences: vec!["public".to_string()], + ..Default::default() + }, + ); + methods.insert( + "internal-only".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/i".to_string(), + audiences: vec!["internal".to_string()], + ..Default::default() + }, + ); + methods.insert( + "untagged".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/u".to_string(), + audiences: vec![], + ..Default::default() + }, + ); + methods.insert( + "multi-tagged".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/m".to_string(), + audiences: vec!["public".to_string(), "internal".to_string()], + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "audiences".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "fixture".to_string(), + resources, + ..Default::default() + } + } + + fn method_names(doc: &RestDescription, group: &str) -> Vec { + let mut names: Vec = doc + .resources + .get(group) + .map(|r| r.methods.keys().cloned().collect()) + .unwrap_or_default(); + names.sort(); + names + } + + /// Empty audience filter is a no-op. Mirrors fern's + /// `audiences.length > 0 && ...` guard in + /// `openapi-ir-parser/generateIr.ts:141` — when no audiences are + /// active, every operation passes through. + #[test] + fn test_filter_doc_by_audiences_empty_is_noop() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &[]); + assert_eq!( + method_names(&doc, "audiences"), + vec!["internal-only", "multi-tagged", "public-only", "untagged"], + ); + } + + /// Single audience keeps only operations whose `x-fern-audiences` + /// contains that tag. Untagged operations are dropped — matches + /// fern's `operationAudiences.includes(...)` over an empty array + /// always evaluating false. + #[test] + fn test_filter_doc_by_audiences_single_keeps_matching_only() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &["public".to_string()]); + assert_eq!( + method_names(&doc, "audiences"), + vec!["multi-tagged", "public-only"], + ); + } + + /// Multiple active audiences union the matches (OR, not AND). + /// Mirrors fern's `audiences.some(a => operationAudiences.includes(a))`: + /// any one match keeps the operation. + #[test] + fn test_filter_doc_by_audiences_multiple_unions_matches() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences( + &mut doc, + &["public".to_string(), "internal".to_string()], + ); + assert_eq!( + method_names(&doc, "audiences"), + vec!["internal-only", "multi-tagged", "public-only"], + ); + } + + /// An audience that no operation declares prunes every operation + /// and then collapses the now-empty resource group. Matches fern's + /// behavior: a preset audience with no matches in the spec yields + /// an empty IR. + #[test] + fn test_filter_doc_by_audiences_nonexistent_prunes_empty_resources() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &["nonexistent".to_string()]); + assert!( + doc.resources.is_empty(), + "filtering all ops out of a group should also prune the empty group itself: \ + {:?}", + doc.resources + ); + } + + /// Audience names are compared as opaque strings — case sensitive, + /// no normalization — to match fern's treatment of audience tags + /// as identifiers. `Public` and `public` do NOT match. + #[test] + fn test_filter_doc_by_audiences_is_case_sensitive() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &["Public".to_string()]); + assert!( + doc.resources.is_empty(), + "case-mismatched audience should match nothing" + ); + } + + /// Nested resources are walked recursively, and an outer resource + /// with only an empty child is itself collapsed. Guards against the + /// recursive prune accidentally leaving orphan parent groups in the + /// command tree. + #[test] + fn test_filter_doc_by_audiences_recurses_into_nested_resources() { + let mut inner_methods = HashMap::new(); + inner_methods.insert( + "ping".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/p".to_string(), + audiences: vec!["public".to_string()], + ..Default::default() + }, + ); + let mut inner_resources = HashMap::new(); + inner_resources.insert( + "v2".to_string(), + RestResource { + methods: inner_methods, + resources: HashMap::new(), + }, + ); + let outer = RestResource { + methods: HashMap::new(), + resources: inner_resources, + }; + let mut resources = HashMap::new(); + resources.insert("audiences".to_string(), outer); + let mut doc = RestDescription { + name: "fixture".to_string(), + resources, + ..Default::default() + }; + + filter_doc_by_audiences(&mut doc, &["public".to_string()]); + let nested = &doc.resources["audiences"].resources["v2"]; + assert!(nested.methods.contains_key("ping")); + + filter_doc_by_audiences(&mut doc, &["nonexistent".to_string()]); + assert!( + doc.resources.is_empty(), + "nested empty groups should propagate up and prune the outer" + ); + } + + // ------------------------------------------------------------------ + // x-fern-groups (FER-9864 P3): document-root metadata that + // re-labels `x-fern-sdk-group-name` group subcommands for the + // help surface. No tree restructuring; the `--help` `about` / + // `long_about` lines for the group's clap Command change when a + // matching entry exists, otherwise the legacy `Operations on + // ''` fallback applies (preserving prior behavior). + // ------------------------------------------------------------------ + + fn make_doc_with_things_resource() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + } + } + + /// Precondition for the rest of the suite: without `x-fern-groups` + /// metadata, the group's clap Command uses the legacy + /// `Operations on ''` about text. This is the fallback the + /// "missing metadata" integration case verifies end-to-end. + #[test] + fn test_group_about_falls_back_to_legacy_label_when_no_metadata() { + let doc = make_doc_with_things_resource(); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'things'", + ); + assert!(things.get_long_about().is_none()); + } + + /// A matching `x-fern-groups` entry with `summary` overrides the + /// fallback `Operations on ''` label on the clap `about()` + /// line. The `summary` text surfaces in both the parent's command + /// table (next to the subcommand name) and in `--help` for the + /// group itself. + #[test] + fn test_group_summary_overrides_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things Operations".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things Operations", + ); + // No `description` → no long_about override; clap will fall + // back to `about` for `--help`. + assert!(things.get_long_about().is_none()); + } + + /// `description` populates `long_about()` so `--help` shows the + /// detailed prose for the group. Setting `description` alone + /// (without `summary`) keeps the legacy short label — fern's IR + /// allows either field to be present without the other and we + /// preserve that asymmetry. + #[test] + fn test_group_description_sets_long_about_only() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Long-form prose about things.".to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'things'", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Long-form prose about things.", + ); + } + + /// Both fields set: `summary` becomes `about`, `description` + /// becomes `long_about`. This is the canonical case the + /// integration test exercises against `--help`. + #[test] + fn test_group_summary_and_description_populate_both_about_fields() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things Operations".to_string()), + description: Some("Long-form prose about things.".to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things Operations", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Long-form prose about things.", + ); + } + + /// Integration case (a) — matched group: spec carries + /// `x-fern-groups: { foo: { summary: "Foo Operations" } }`, two + /// operations are tagged `x-fern-sdk-group-name: foo`, and one is + /// untagged. Verifies the `foo` subcommand's `about()` line is + /// `Foo Operations` (driven by `summary`) and that the untagged + /// op lands on its tag-derived sibling group with the legacy + /// fallback label. + /// + /// Drives the parser end-to-end so the full path + /// (YAML → `OpenApiSpec` → `RestDescription` → `Command`) is + /// covered. + #[test] + fn test_x_fern_groups_summary_drives_about_for_matched_group() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + foo: + summary: Foo Operations + description: Operations on foo resources. +paths: + /foo/list: + get: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: list + operationId: foo_list + responses: + "200": { description: ok } + /foo/create: + post: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: create + operationId: foo_create + responses: + "200": { description: ok } + /misc: + get: + tags: [Misc] + x-fern-sdk-method-name: list + operationId: misc_list + responses: + "200": { description: ok } +"#; + let doc = crate::openapi::load_openapi_spec(yaml, "test-cli").unwrap(); + let cmd = build_cli(&doc); + + // Matched group: `summary` wins over the legacy fallback. + let foo = cmd + .find_subcommand("foo") + .expect("foo subcommand should exist"); + assert_eq!( + foo.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Foo Operations", + ); + assert_eq!( + foo.get_long_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on foo resources.", + ); + // Group children are still present — `x-fern-groups` does not + // restructure the clap tree. + assert!(foo.find_subcommand("list").is_some()); + assert!(foo.find_subcommand("create").is_some()); + } + + /// Integration case (b) — missing-metadata fallback: an operation + /// carries `x-fern-sdk-group-name: [bar]` but the spec has no + /// matching `x-fern-groups: bar` entry. The CLI must build + /// without error and the `bar` subcommand keeps the legacy + /// `Operations on 'bar'` about line. Verifies no crash and clean + /// fallback when only one side of the pair is present. + #[test] + fn test_x_fern_groups_missing_entry_falls_back_to_default_label() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +paths: + /bar/list: + get: + x-fern-sdk-group-name: [bar] + x-fern-sdk-method-name: list + operationId: bar_list + responses: + "200": { description: ok } +"#; + let doc = crate::openapi::load_openapi_spec(yaml, "test-cli").unwrap(); + assert!( + doc.groups.is_empty(), + "no x-fern-groups → groups map should be empty", + ); + let cmd = build_cli(&doc); + let bar = cmd + .find_subcommand("bar") + .expect("bar subcommand should exist"); + assert_eq!( + bar.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'bar'", + ); + assert!(bar.get_long_about().is_none()); + assert!(bar.find_subcommand("list").is_some()); + } + + /// `x-fern-groups` is purely metadata for rendering — adding it + /// must NOT change which subcommands exist, their nesting, or + /// their leaf method commands. This guards the hard constraint + /// that the clap tree structure stays untouched. + #[test] + fn test_x_fern_groups_does_not_restructure_clap_tree() { + let yaml_without = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +paths: + /foo/list: + get: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: list + operationId: foo_list + responses: + "200": { description: ok } +"#; + let yaml_with = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + foo: + summary: Foo Operations +paths: + /foo/list: + get: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: list + operationId: foo_list + responses: + "200": { description: ok } +"#; + let collect_tree = |yaml: &str| -> Vec { + let doc = crate::openapi::load_openapi_spec(yaml, "test-cli").unwrap(); + let cmd = build_cli(&doc); + let mut out = Vec::new(); + for sub in cmd.get_subcommands() { + for leaf in sub.get_subcommands() { + out.push(format!("{}.{}", sub.get_name(), leaf.get_name())); + } + } + out.sort(); + out + }; + assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/discovery.rs new file mode 100644 index 000000000000..3f67f8a2228a --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/discovery.rs @@ -0,0 +1,1129 @@ +//! Internal OpenAPI Representation +//! +//! Data structures representing an OpenAPI API's resources, methods, parameters, and schemas. +//! These structs serve as the internal representation that the command builder and +//! executor consume. + +use std::collections::HashMap; + +use serde::Deserialize; + +/// Top-level API description. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RestDescription { + pub name: String, + pub version: String, + pub title: Option, + pub description: Option, + pub root_url: String, + /// All top-level `servers:` entries from the spec, in declaration order. + /// `root_url` is the URL of the first entry (kept for backwards + /// compatibility with existing call sites). Servers with a resolved + /// `name` (from `x-name`, falling back to `x-fern-server-name`) drive + /// the global `--server ` flag — when the spec has at least one + /// named server, the flag is exposed and its allowed values are the + /// top-level named server names. Unnamed entries are still preserved + /// here so the order matches the spec; helpers like + /// [`RestDescription::named_servers`] filter them out for the help + /// surface. + #[serde(default, skip)] + pub servers: Vec, + #[serde(default)] + pub service_path: String, + pub base_url: Option, + /// Common prefix prepended to every operation path at request time — + /// sourced from the spec-level `x-fern-base-path` extension. Used to + /// declare a shared base like `/v1` or `/api/public` once on the spec + /// instead of duplicating it on every path. Mirrors the upstream + /// Fern OpenAPI importer: + /// + /// + /// At request time the executor inserts this between the server URL + /// and the operation path with exactly one slash between segments. + /// An empty string is treated the same as `None`. + pub base_path: Option, + #[serde(default)] + pub schemas: HashMap, + #[serde(default)] + pub resources: HashMap, + #[serde(default)] + pub parameters: HashMap, + pub auth: Option, + /// Auth schemes declared in `components.securitySchemes`. The key is the + /// scheme name as it appears in the spec — that name is what + /// per-operation `security` requirements reference, and what + /// `CliApp::auth_scheme(name, source)` binds a credential source to. + #[serde(default)] + pub security_schemes: HashMap, + /// Query parameter name for pagination tokens (default: "pageToken"). + #[serde(default)] + pub pagination_token_query_param: Option, + /// Dotted path to next page token in response JSON (default: "nextPageToken"). + /// Supports nested paths like "pagination.next_page_token". + #[serde(default)] + pub pagination_token_response_path: Option, + /// Idempotency header definitions parsed from the spec-root + /// [`x-fern-idempotency-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotency-headers) + /// extension. Empty when the extension is absent. + /// + /// Each entry describes a header that operations marked with + /// `x-fern-idempotent: true` accept. The parser materializes one CLI + /// flag per header on every idempotent operation; non-idempotent + /// operations are unaffected and never send these headers. + #[serde(default, skip)] + pub idempotency_headers: Vec, + /// Constructor-style globals declared by the spec's top-level + /// `x-fern-sdk-variables` extension. Each entry surfaces as a global + /// CLI flag (kebab-cased) with an env-var fallback + /// (SCREAMING_SNAKE_CASE of the variable name) and replaces the + /// corresponding `{varName}` placeholder in path templates of + /// operations whose path parameter carries `x-fern-sdk-variable`. + /// + /// See . + #[serde(default, skip)] + pub sdk_variables: Vec, + /// Spec-root [`x-fern-retries`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/retries) + /// extension. Inherited by every operation that omits its own + /// `x-fern-retries` block, or that sets `x-fern-retries: true` to + /// opt in to the spec-root defaults. A per-op object merges over + /// this baseline; a per-op `false` (or `{ disabled: true }`) + /// disables retries on that operation regardless of root. + #[serde(default, skip)] + pub retries: Option, + /// Global header definitions parsed from the spec-root + /// [`x-fern-global-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/global-headers) + /// extension. Empty when the extension is absent. + /// + /// Each entry surfaces as a global CLI flag at the root of the + /// command tree with an env-var fallback and (when configured) a + /// baked-in default value. The resolved value is stamped on every + /// outgoing request as the named HTTP header — per-operation + /// parameters with the same wire-name win. + #[serde(default, skip)] + pub global_headers: Vec, + /// Top-level group metadata sourced from the document-root + /// [`x-fern-groups`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/groups) + /// extension. Mirrors the upstream Fern OpenAPI importer's + /// [`SdkGroupInfo`](https://github.com/fern-api/fern/blob/main/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernGroups.ts) + /// record (`{ summary?, description? }`). + /// + /// Keys are kebab-cased to match the resource keys built from + /// `x-fern-sdk-group-name`, so a `foo` entry binds to the `foo` + /// subcommand resource and a `myGroup` entry binds to the + /// `my-group` resource. Values are purely metadata for `--help` + /// rendering — `x-fern-groups` does NOT restructure the clap tree, + /// matching fern's semantics where the extension only annotates + /// existing groups for documentation. + #[serde(default, skip)] + pub groups: HashMap, +} + +/// Metadata for a single group declared via the spec-root +/// [`x-fern-groups`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/groups) +/// extension. +/// +/// Mirrors fern's `SdkGroupInfo` IR type (both fields optional): +/// +/// (`SdkGroupInfo { summary: optional, description: optional }`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SdkGroupInfo { + /// Short human-friendly label for the group. When present, replaces + /// the default `Operations on ''` text used as the clap + /// subcommand's `about()` line. + pub summary: Option, + /// Longer prose description of the group. When present, used as the + /// clap subcommand's `long_about()` so `--help` shows the full text + /// for the group. + pub description: Option, +} + +/// A single global header definition from the spec-root +/// [`x-fern-global-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/global-headers) +/// extension. Mirrors the [`GlobalHeader`] IR type emitted by the +/// upstream Fern OpenAPI importer. +/// +/// The CLI uses `name` (if set) to derive the kebab-cased flag name and +/// `header` as the on-the-wire HTTP header name. When `env` is set the +/// flag accepts the value from that environment variable as a fallback, +/// and `default` is used when neither the flag nor the env var is +/// supplied. Operations may opt out of sending the header by declaring +/// a same-named per-operation parameter, which takes precedence. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GlobalHeader { + /// HTTP header name sent on the wire (e.g. `X-API-Version`). + pub header: String, + /// Optional SDK/CLI parameter name. When set, used as the basis for + /// the kebab-cased CLI flag name; otherwise the flag derives from + /// `header`. + pub name: Option, + /// When `false` (the default), the CLI flag is required — every + /// outgoing request must carry a value. When `true`, the header is + /// omitted from requests where no value resolved. + pub optional: bool, + /// Optional environment variable that provides a fallback value for + /// the generated flag. + pub env: Option, + /// Optional baked-in default value applied when neither the flag + /// nor the environment variable is supplied. Mirrors the upstream + /// `x-fern-default` shape — only the value is preserved; the + /// schema type is informational. + pub default: Option, +} + +/// A single idempotency-header definition from the spec-root +/// [`x-fern-idempotency-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotency-headers) +/// extension. Mirrors the [`IdempotencyHeader`] IR type emitted by the +/// upstream Fern OpenAPI importer. +/// +/// The CLI uses `name` (if set) to derive the kebab-cased flag name and +/// `header` as the on-the-wire HTTP header name. When `env` is set the +/// flag accepts the value from that environment variable as a fallback. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct IdempotencyHeader { + /// HTTP header name sent on the wire (e.g. `Idempotency-Key`). + pub header: String, + /// Optional SDK/CLI parameter name. When set, used as the basis for + /// the kebab-cased CLI flag name; otherwise the flag derives from + /// `header`. + pub name: Option, + /// Optional environment variable that provides a default value for + /// the generated flag. Generators can override this at build time via + /// [`crate::openapi::app::CliApp::idempotency_header_env`]. + pub env: Option, +} + +/// A spec-level `x-fern-sdk-variables` entry. Modeled as a constructor-style +/// global that operations can bind path parameters to via +/// `x-fern-sdk-variable: `. +/// +/// Fern's TS/Python/Java SDKs only support `type: string` here today, so the +/// parser warns and skips non-string entries (mirroring the upstream +/// importer's `Variable has unsupported schema` rejection but without +/// failing the whole spec load — the CLI is intentionally permissive). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SdkVariable { + /// Variable name as it appears in path templates (e.g. `gardenId`). + pub name: String, + /// Lowered OpenAPI primitive type. Always `string` today; carried so a + /// future generator change can specialize the global flag's `value_name`. + pub ty: String, + /// One-line `--help` description (from the variable schema's + /// `description:` field). + pub description: Option, +} + +/// How the request body should be serialized on the wire. +/// +/// Determines the `Content-Type` header and payload encoding strategy. +/// Modeled as an enum so future body formats (multipart/form-data, etc.) +/// can be added as variants without boolean proliferation. +/// +/// ## OpenAPI form encoding options (future work) +/// +/// For `FormUrlEncoded`, the OAS 3.x `encoding` map supports per-property +/// overrides: `style` (form | spaceDelimited | pipeDelimited | deepObject), +/// `explode` (true | false), `contentType`, and `allowReserved`. These are +/// not yet parsed or acted upon — the current implementation uses the +/// defaults (`style: form`, `explode: true`) which produce repeated keys +/// for arrays (e.g. `tag=a&tag=b`). When a real consumer needs non-default +/// serialization, these fields should be added to the `FormUrlEncoded` +/// variant as a `HashMap`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BodyEncoding { + /// `application/json` — the default encoding for request bodies. + #[default] + Json, + /// `application/x-www-form-urlencoded` — flat key=value pairs. + /// + /// Current behavior: top-level keys emitted as-is, arrays repeat the + /// key (`tag=a&tag=b`), nested objects are JSON-encoded as values. + FormUrlEncoded, + // Future variants: + // MultipartFormData { encoding: HashMap }, +} + +impl BodyEncoding { + /// The `Content-Type` header value for this encoding. + pub fn content_type(&self) -> &'static str { + match self { + Self::Json => "application/json", + Self::FormUrlEncoded => "application/x-www-form-urlencoded", + } + } + + /// Returns `true` when the encoding is form-urlencoded. + pub fn is_form(&self) -> bool { + matches!(self, Self::FormUrlEncoded) + } +} + +/// Lifecycle/availability of an operation or parameter, sourced from the +/// `x-fern-availability` extension on the OpenAPI element. Mirrors the +/// canonical Fern values documented at +/// . +/// +/// `Deprecated` is also reached when an operation has no +/// `x-fern-availability` extension but does carry the OpenAPI +/// `deprecated: true` flag — in that case the parser surfaces +/// `Deprecated` (see `parser.rs`). +/// +/// NOTE: deliberate divergence from the Fern OpenAPI IR importer +/// (`packages/cli/api-importers/openapi/openapi-ir-parser`): the importer +/// collapses `pre-release` into [`Availability::Beta`] in the IR, since +/// downstream SDK generators only need to know "is this stable" / +/// "is this pre-stable" / "is this gone". The cli-sdk parser keeps +/// `PreRelease` as its own variant so the help-output badge can +/// differentiate `[PRE-RELEASE]` from `[BETA]` — both are documented +/// values in the [Fern reference], and treating them as the same loses +/// signal at the CLI surface where the user is reading help text. +/// +/// [Fern reference]: https://buildwithfern.com/learn/api-definitions/openapi/extensions/availability +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Availability { + /// Pre-stable, in active development. Tagged `[ALPHA]` in help output. + Alpha, + /// Pre-release / preview API. Tagged `[PRE-RELEASE]` in help output. + /// Distinct from [`Availability::Beta`] in cli-sdk; see enum docs. + PreRelease, + /// Public beta. Tagged `[BETA]` in help output. + Beta, + /// Public preview. Tagged `[PREVIEW]` in help output. + Preview, + /// Generally available. No badge — this is the implicit default when + /// `x-fern-availability` is absent. Accepts `ga` as an alias (matches + /// the Fern OpenAPI importer). + #[serde(alias = "ga")] + GenerallyAvailable, + /// Deprecated; still callable but discouraged. Tagged `[DEPRECATED]` + /// in help output. Also inferred from OpenAPI `deprecated: true`. + Deprecated, + /// Legacy / sunset API. Tagged `[LEGACY]` in help output. + Legacy, +} + +impl Availability { + /// Returns the badge label used in CLI help output for this + /// availability, or `None` for [`Availability::GenerallyAvailable`] + /// (the implicit default — no badge). + pub fn badge(self) -> Option<&'static str> { + match self { + Availability::Alpha => Some("[ALPHA]"), + Availability::PreRelease => Some("[PRE-RELEASE]"), + Availability::Beta => Some("[BETA]"), + Availability::Preview => Some("[PREVIEW]"), + Availability::GenerallyAvailable => None, + Availability::Deprecated => Some("[DEPRECATED]"), + Availability::Legacy => Some("[LEGACY]"), + } + } + + /// Lowercase wire identifier matching the canonical Fern spelling + /// (`alpha`, `beta`, `pre-release`, `preview`, `generally-available`, + /// `deprecated`, `legacy`). Used for the `availability` field + /// surfaced in JSON help output. + pub fn as_str(self) -> &'static str { + match self { + Availability::Alpha => "alpha", + Availability::PreRelease => "pre-release", + Availability::Beta => "beta", + Availability::Preview => "preview", + Availability::GenerallyAvailable => "generally-available", + Availability::Deprecated => "deprecated", + Availability::Legacy => "legacy", + } + } +} + +/// A single auth scheme declared in `components.securitySchemes`. Mirrors +/// the OpenAPI 3 Security Scheme Object, lowered to just the bits we +/// dispatch on at runtime. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub enum SecurityScheme { + /// `type: http, scheme: bearer` → `Authorization: Bearer `. + HttpBearer, + /// `type: http, scheme: basic` → `Authorization: Basic `. + HttpBasic, + /// `type: apiKey, in: header, name: X-Api-Key` → `: `. + ApiKeyHeader { name: String }, + /// `type: apiKey, in: query, name: api_key` — represented for parsing + /// fidelity. The CLI doesn't attach query-key auth itself today; + /// `RoutingAuthProvider` will skip a requirement that names this scheme. + ApiKeyQuery { name: String }, + /// `type: oauth2`. The CLI treats these the same as `HttpBearer` at + /// request time — the user supplies an already-issued access token via + /// env var. Token refresh is out of scope. + OAuth2, + /// Anything we don't model (mTLS, openIdConnect, etc.). Recorded so the + /// scheme name is still routable if a separate provider is bound to it + /// programmatically. + Other(String), +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct AuthDescription { + pub oauth2: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct OAuth2Description { + pub scopes: Option>, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ScopeDescription { + pub description: Option, +} + +/// A resource which can contain methods and nested sub-resources. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct RestResource { + #[serde(default)] + pub methods: HashMap, + #[serde(default)] + pub resources: HashMap, +} + +/// One entry from an OpenAPI `servers:` array (top-level or per-operation), +/// lowered into the internal representation. +/// +/// `name` is populated from the Fern extensions `x-name` (v1, the +/// legacy alias) or `x-fern-server-name` (v2, the canonical Fern +/// spelling). When both are present on the same server entry, v1 wins +/// to mirror fern's `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` +/// first-match-wins semantics in +/// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`. +/// Unnamed servers (no `x-fern-server-name` and no `x-name`) carry +/// `None`; they still participate in the default-URL chain (first +/// server wins) but are not selectable via the global `--server ` +/// flag. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Server { + /// Server URL as it appears in the spec (may contain `{variable}` + /// placeholders that are substituted later by [`CliApp::server_var`]). + pub url: String, + /// Resolved server name from `x-name` (v1 legacy alias, preferred to + /// mirror fern) or `x-fern-server-name` (v2 canonical Fern spelling). + /// `None` for unnamed entries. + pub name: Option, + /// Optional human-readable description from the spec — surfaced in + /// `--help` next to the server URL. + pub description: Option, +} + +impl RestDescription { + /// Returns the top-level servers that have a resolved name, paired + /// with the resolved name itself, in declaration order. Drives the + /// global `--server ` flag's allowed values and the + /// help-section listing. + /// + /// Yielding `(name, server)` tuples lets callers avoid re-checking + /// `server.name.is_some()` after the filter — the name is right + /// there, statically guaranteed to be non-empty (see + /// [`OpenApiServer::resolved_name`] in the parser, which trims and + /// drops empty strings at the source). + pub fn named_servers(&self) -> impl Iterator { + self.servers + .iter() + .filter_map(|s| s.name.as_deref().map(|n| (n, s))) + } +} + +/// Default total attempts (initial + retries) when retries are enabled. +/// +/// CLI users typically don't expect retries by default — they want fast, +/// observable failures — so we ship a *conservative* default that retries +/// at most once. The spec author can override this with +/// `x-fern-retries: { max_attempts: N }`. +/// +/// This is deliberately lower than the fern Python/TypeScript runtime SDKs +/// (which default to 3 total attempts) because those SDKs are embedded in +/// long-running applications where the latency of an extra retry is +/// acceptable. The CLI is interactive — a 3-second backoff before the +/// final failure feels broken. +pub const DEFAULT_RETRY_MAX_ATTEMPTS: u32 = 2; + +/// Default exponential-backoff base delay in milliseconds. The wait before +/// retry N is `base * factor^N` (plus jitter). With +/// [`DEFAULT_RETRY_MAX_ATTEMPTS`] = 2 the single retry happens after +/// `base * factor^0` = 250ms. +pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 250; + +/// Default exponential-backoff growth factor. +pub const DEFAULT_RETRY_FACTOR: f64 = 2.0; + +/// Default jitter fraction (`0.1` = ±10% of the computed delay). +pub const DEFAULT_RETRY_JITTER: f64 = 0.1; + +/// Resolved retry policy for an endpoint (or the spec-root default), +/// lowered from the [`x-fern-retries`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/retries) +/// extension. +/// +/// Mirrors the upstream Fern OpenAPI importer's tagged shape — the +/// canonical lever is `disabled: bool`, which the importer surfaces as +/// `RetriesConfiguration::Disabled(value)`. cli-sdk extends the same +/// extension with optional knobs that the runtime retry loop honors at +/// request time: `max_attempts`, `base_delay_ms`, `factor`, `jitter`. The +/// extra knobs are forward-compatible with the upstream importer — they +/// are simply ignored on the fern side until the IR carries them. +/// +/// Resolution precedence (handled by the parser): +/// - per-op block absent → inherit the spec-root block (or `None` if also absent) +/// - per-op `true` → spec-root config, or all-defaults when root is absent +/// - per-op `false` (or `{ disabled: true }`) → disabled regardless of root +/// - per-op object → root values, overridden field-by-field by the op block +#[derive(Debug, Clone, PartialEq)] +pub struct RetriesConfig { + /// `true` (the default) means the executor's retry loop is active; + /// `false` disables retries for the operation. Maps to upstream + /// fern's `RetriesConfiguration::Disabled(value)` — the importer's + /// `disabled: true` lowers here as `enabled: false`. + pub enabled: bool, + /// Maximum total attempts (the initial request counts as attempt 1). + /// `max_attempts: 2` performs the request once and retries up to one + /// additional time. Validated as `>= 0` at parse time; a value of + /// `0` is treated identically to `disabled: true`. + pub max_attempts: u32, + /// Base delay between retries in milliseconds. The actual wait before + /// retry `n` (1-indexed) is `base_delay_ms * factor^(n-1)`, plus + /// optional jitter, capped by any server-supplied `Retry-After`. + pub base_delay_ms: u64, + /// Growth factor for exponential backoff (e.g. `2.0` doubles the + /// delay each retry). + pub factor: f64, + /// Jitter fraction in `[0.0, 1.0]`. A value of `0.1` adds a uniform + /// random offset in `±10%` of the computed delay so a stampede of + /// clients does not synchronize retries. + pub jitter: f64, +} + +impl Default for RetriesConfig { + fn default() -> Self { + Self { + enabled: true, + max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS, + base_delay_ms: DEFAULT_RETRY_BASE_DELAY_MS, + factor: DEFAULT_RETRY_FACTOR, + jitter: DEFAULT_RETRY_JITTER, + } + } +} + +impl RetriesConfig { + /// Explicitly-disabled retry policy. Returned by the parser when the + /// spec sets `x-fern-retries: false` or `{ disabled: true }`. The + /// executor short-circuits on this variant — no retry loop, no + /// backoff, no Retry-After honor. + pub fn disabled() -> Self { + Self { + enabled: false, + max_attempts: 0, + base_delay_ms: 0, + factor: DEFAULT_RETRY_FACTOR, + jitter: 0.0, + } + } +} + +/// A single API method. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RestMethod { + pub id: Option, + pub description: Option, + pub http_method: String, + pub path: String, + #[serde(default)] + pub parameters: HashMap, + #[serde(default)] + pub parameter_order: Vec, + pub request: Option, + pub response: Option, + #[serde(default)] + pub scopes: Vec, + pub flat_path: Option, + #[serde(default)] + pub supports_media_download: bool, + #[serde(default)] + pub supports_media_upload: bool, + pub media_upload: Option, + /// Per-operation base URL (populated from the spec's servers block during parsing). + /// When non-empty, takes priority over RestDescription.root_url in URL construction. + #[serde(default)] + pub root_url: String, + /// Per-operation `servers:` overrides (named or unnamed), in declaration + /// order. Empty when the operation has no `servers:` block (the + /// top-level [`RestDescription::servers`] applies instead). + /// + /// When non-empty, this list is the authoritative server set for the + /// operation — per-op `servers:` *replaces* the global default, it does + /// not augment it. The global `--server ` flag resolves against + /// this list first for operations that have it; if the flag value + /// doesn't match any per-op name, the executor falls back to + /// [`RestMethod::root_url`] (the first per-op server) so per-op routing + /// overrides are preserved. + #[serde(default, skip)] + pub servers: Vec, + /// Metadata for operations whose request body is raw binary (e.g. + /// `application/octet-stream`, `audio/mpeg`). When `Some`, the CLI exposes + /// a typed flag that streams a file as the body with the declared content + /// type. + #[serde(default)] + pub binary_request_body: Option, + /// How the request body should be serialized on the wire. + /// + /// Defaults to `BodyEncoding::Json`. The executor reads this to decide + /// the `Content-Type` header and encoding strategy. + #[serde(default)] + pub body_encoding: BodyEncoding, + /// Lowered OpenAPI security requirements: OR of ANDs. + /// + /// - `None` — operation didn't declare `security` and there was no + /// spec-level default to inherit. + /// - `Some(vec![])` — operation explicitly opts out (`security: []` in + /// the spec, or inherited explicit empty). + /// - `Some(vec![req1, req2, ...])` — satisfy any one requirement; each + /// requirement is an AND of scheme names with their requested scopes. + #[serde(default)] + pub security_requirements: Option>>>, + /// Resolved `x-fern-pagination` extension for this operation, after + /// applying root-level inheritance (per-op `x-fern-pagination: true` + /// inherits from the spec-root `x-fern-pagination` block). + /// + /// `None` means the operation has no explicit pagination config — the + /// executor falls back to the document-wide heuristic + /// (`pagination_token_query_param` + `pagination_token_response_path`). + #[serde(default, skip)] + pub pagination: Option, + /// Lowered `x-fern-availability` for the operation. `None` is the + /// implicit default (no badge). When the extension is absent but the + /// operation carries `deprecated: true`, the parser sets this to + /// `Some(Availability::Deprecated)` so the standard OpenAPI flag is + /// honored. + #[serde(default)] + pub availability: Option, + /// `true` when the operation is marked with + /// [`x-fern-idempotent: true`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotent). + /// Idempotent operations surface the spec-root idempotency-header + /// definitions as CLI flags; non-idempotent operations do not, and + /// never send idempotency headers on the wire. + #[serde(default)] + pub idempotent: bool, + /// Resolved `x-fern-sdk-return-value` extension — a dot-separated key + /// path through the JSON response body identifying the subvalue the + /// SDK / CLI should return to the caller. `None` (the implicit + /// default) means the executor prints the full response. + /// + /// Mirrors fern-api/fern's OpenAPI importer + /// (`FernOpenAPIExtension.RESPONSE_PROPERTY = "x-fern-sdk-return-value"`): + /// the value is consumed as a property path on the response body, + /// surfacing only the named subvalue. cli-sdk extends this to + /// support nested paths (e.g. `result.items`) at runtime — the + /// upstream Fern Definition path resolves a single object property, + /// but the CLI executor walks dotted paths the same way it does for + /// `x-fern-pagination`'s `next_*` / `results` paths. + #[serde(default)] + pub return_value: Option, + /// Resolved `x-fern-streaming` extension. `None` means the operation + /// returns a unary response and the executor reads/buffers the body + /// normally. `Some(_)` opts the executor into incremental + /// line-by-line response handling: each event/value is decoded as it + /// arrives and emitted to stdout (or buffered when `--no-stream` is + /// set). Mirrors the upstream Fern OpenAPI importer's + /// `getFernStreamingExtension` + /// (`fern-api/fern/.../extensions/getFernStreamingExtension.ts`). + /// + /// The runtime variant carries only what the executor needs at + /// request time: the wire format (SSE vs newline-delimited JSON) and + /// an optional terminator line. Upstream's `stream-condition` form + /// (which generates a streaming-and-unary endpoint pair in typed + /// SDKs) is parsed for parity but is not surfaced at the CLI + /// runtime — the CLI exposes one command per OpenAPI operation, so + /// the boolean stream-condition is treated as an unconditional + /// stream. + #[serde(default, skip)] + pub streaming: Option, + /// Resolved `x-fern-retries` extension for this operation, after + /// applying root-level inheritance (per-op `true` adopts the spec-root + /// baseline; per-op object merges field-by-field over root). `None` + /// means the operation has no retry policy at all — the executor + /// runs the request exactly once. See [`RetriesConfig`] for the + /// precedence rules. + #[serde(default, skip)] + pub retries: Option, + /// Resolved [`x-fern-audiences`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/audiences) + /// tags for this operation, in declaration order with duplicates + /// preserved. Empty when the operation has no `x-fern-audiences` + /// extension. + /// + /// Used by the audience-filter pass at command-tree build time + /// (`commands::filter_doc_by_audiences`) to decide whether the + /// operation appears as a CLI subcommand. Untouched at request + /// time — the executor never inspects this field, matching fern's + /// "drop from IR" semantics rather than "skip at runtime". + /// + /// Mirrors fern-api/fern's OpenAPI importer + /// (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/operation/convertHttpOperation.ts:330`): + /// `audiences: getExtension(operation, FernOpenAPIExtension.AUDIENCES) ?? []`. + /// + /// `skip` mirrors the convention used by peer internal-only + /// fields parsed from `x-fern-*` extensions (`retries`, + /// `streaming`, `pagination`) — set programmatically by the + /// parser, never round-tripped through `RestMethod` serialization. + #[serde(default, skip)] + pub audiences: Vec, +} + +/// Per-operation pagination configuration, resolved from the +/// [`x-fern-pagination`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/pagination) +/// OpenAPI extension. +/// +/// The five forms mirror `fern-api/fern`'s OpenAPI importer (see +/// `getPaginationExtension.ts`): +/// +/// - [`PaginationConfig::Cursor`] — token-based forward pagination +/// - [`PaginationConfig::Offset`] — numeric offset pagination +/// - [`PaginationConfig::Uri`] — server returns a fully-formed next URL +/// - [`PaginationConfig::Path`] — server returns a relative next path +/// - [`PaginationConfig::Custom`] — caller-driven; the executor stops after +/// one request (no automatic continuation) and exposes only the +/// `results` extraction +/// +/// `$request.` / `$response.` JSONPath prefixes are stripped during +/// parsing so values can be consumed directly: `cursor` / `offset` are the +/// request parameter name to populate on the next page, and `next_*`, +/// `results`, `has_next_page` are dotted JSON paths into the response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaginationConfig { + /// Cursor-style pagination — send the previous response's + /// `next_cursor` value as the request's `cursor` parameter on the next + /// page. Pagination stops when `next_cursor` is absent, null, or empty. + Cursor { + /// Request parameter name receiving the cursor token. + cursor: String, + /// Dotted JSON path in the response to the next cursor token. + next_cursor: String, + /// Dotted JSON path in the response to the results array. + results: String, + }, + /// Offset-style pagination — send the running offset as the request's + /// `offset` parameter on each page. Pagination stops when + /// `has_next_page` is `false`, when the results array is empty, or + /// when the configured page limit is reached. + Offset { + /// Request parameter name receiving the offset value. + offset: String, + /// Dotted JSON path in the response to the results array. + results: String, + /// Optional request parameter name holding the page-size step. When + /// present, the offset advances by the step value the caller + /// supplied (e.g. `--params '{"limit": 50}'`). When absent, the + /// offset advances by the response page's results length. + step: Option, + /// Optional dotted JSON path in the response to a boolean + /// "more pages?" flag. + has_next_page: Option, + }, + /// URI pagination — the server returns a fully-formed URL for the + /// next page (e.g. `https://api.example.com/v1/things?cursor=abc`). + /// The executor uses that URL verbatim for the next request. + /// Pagination stops when the URL is absent, null, or empty. + Uri { + /// Dotted JSON path in the response to the next-page URL. + next_uri: String, + /// Dotted JSON path in the response to the results array. + results: String, + }, + /// Path pagination — like [`PaginationConfig::Uri`] but the response + /// contains a relative path (e.g. `/v1/things?cursor=abc`) that the + /// executor resolves against the original request's base URL. + /// Pagination stops when the path is absent, null, or empty. + Path { + /// Dotted JSON path in the response to the next-page path. + next_path: String, + /// Dotted JSON path in the response to the results array. + results: String, + }, + /// Custom pagination — caller-driven. The CLI does not attempt + /// automatic continuation; it issues exactly one request and only + /// uses the `results` path for result extraction. + Custom { + /// Dotted JSON path in the response to the results array. + results: String, + }, +} + +/// Per-operation streaming configuration, resolved from the +/// [`x-fern-streaming`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/streaming) +/// OpenAPI extension. Mirrors the upstream Fern OpenAPI importer's +/// `getFernStreamingExtension` tagged union — the three wire formats +/// the runtime distinguishes (`sse`, `json`, `text`) line up with +/// Fern IR's `StreamingResponse` union (see +/// `packages/ir-sdk/fern/apis/ir-types-latest/definition/http.yml`). +/// +/// Recognized YAML shapes (parser side): +/// - `x-fern-streaming: true` → [`StreamingConfig::Json`] with no terminator +/// (matches upstream's boolean shorthand: `format: "json"`). +/// - `x-fern-streaming: false` → `None` (explicit opt-out). +/// - `x-fern-streaming: { format: sse }` → [`StreamingConfig::Sse`]. +/// - `x-fern-streaming: { format: json }` → [`StreamingConfig::Json`]. +/// - `x-fern-streaming: { format: text }` → [`StreamingConfig::Text`]. +/// - `{ format: sse, terminator: "[DONE]" }` → SSE with explicit terminator. +/// +/// The optional `terminator` is the literal line that ends the stream +/// — for SSE, the event payload after the `data:` prefix; for JSON, +/// the full line. When unset, the executor reads until the server +/// closes the connection (matches the TS / C# typed-SDK runtimes, +/// which also skip the terminator check when the spec didn't declare +/// one). Text streams have no terminator concept. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StreamingConfig { + /// Server-Sent Events stream (`format: sse`). Body is parsed line + /// by line; lines beginning with `data: ` have the prefix stripped + /// and the remainder is emitted as one event. Other SSE field + /// lines (`event:`, `id:`, `retry:`, comment lines starting with + /// `:`) are ignored at runtime. + Sse { + /// Optional sentinel line that terminates the stream + /// (compared against the post-`data: ` event payload using + /// exact equality, matching the C# generator). When `None`, + /// the stream reads to EOF; mirrors the TS/C# typed-SDK + /// behavior of only checking the terminator when the spec + /// declared one. + terminator: Option, + }, + /// Newline-delimited JSON stream (`format: json`, aka NDJSON / + /// JSONL). Each non-empty line is a complete JSON value; the + /// executor parses one value per line and emits it as it arrives. + Json { + /// Optional sentinel line that terminates the stream (compared + /// against the raw line, before JSON parsing). When `None`, + /// the stream ends when the server closes the connection. + terminator: Option, + }, + /// Plain-text line stream (`format: text`). Each non-empty line is + /// emitted verbatim as a raw string event — no JSON parsing, no + /// SSE framing strip, no terminator check. Mirrors the C# SDK + /// generator (`HttpEndpointGenerator.ts:815-825`), which reads + /// the response line-by-line and `yield return line` for any + /// non-empty line. + /// + /// `x-fern-sdk-return-value` is a no-op for text streams — the + /// event payload is already a JSON string after escaping. + Text, +} + +/// Metadata describing a binary request body. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct BinaryRequestBody { + /// Content type to send with the request (e.g. `application/octet-stream`). + pub content_type: String, + /// CLI flag name (kebab-cased). Resolved from `x-fern-parameter-name` on + /// the requestBody when present; falls back to `file` for `format: binary` + /// schemas, otherwise `body`. + pub flag_name: String, +} + +/// Media upload metadata. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MediaUpload { + pub protocols: Option, + pub accept: Option>, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MediaUploadProtocols { + pub simple: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MediaUploadProtocol { + pub path: String, + pub multipart: Option, +} + +/// A reference to a schema (e.g., `{ "$ref": "File" }`). +#[derive(Debug, Clone, Deserialize, Default)] +pub struct SchemaRef { + #[serde(rename = "$ref")] + pub schema_ref: Option, + #[serde(rename = "parameterName")] + pub parameter_name: Option, +} + +/// A parameter definition for a method. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MethodParameter { + #[serde(rename = "type")] + pub param_type: Option, + pub description: Option, + pub location: Option, + #[serde(default)] + pub required: bool, + pub format: Option, + /// Client-side default sourced only from the Fern `x-fern-default` + /// extension. When set, the generated CLI plumbs this into clap's + /// `.default_value(...)` (so the value shows up in `--help` and the + /// flag becomes optional) AND substitutes the original JSON value + /// into the outgoing request when the caller omits the flag. Stored + /// as a typed `serde_json::Value` so numbers/booleans keep their + /// wire type. + /// + /// Precedence within this field, **first match wins**: + /// 1. `x-fern-default` placed at the ref-site (next to `$ref`) + /// 2. `x-fern-default` on the resolved component parameter + /// + /// The OpenAPI standard `default:` keyword does **not** populate + /// this field — it lives separately on + /// [`documentation_default_value`]. See ticket FER-9864. + pub default_value: Option, + /// Documentation hint sourced from the OpenAPI standard `default:` + /// keyword on the parameter's `schema`. The OpenAPI spec defines + /// `default:` as describing **server** behavior when the parameter + /// is omitted — it is not a directive to clients to send the value. + /// + /// We surface this in `--help` (so users know what the API will do + /// if they leave the flag off) but we do **not** wire it into + /// clap's `.default_value(...)` and we do **not** send it on the + /// wire. Only `x-fern-default` (stored on [`default_value`]) + /// produces a client-side default. + /// + /// Ignored when `default_value` is set — the extension supersedes + /// the documentation hint for display purposes too. + pub documentation_default_value: Option, + #[serde(rename = "enum")] + pub enum_values: Option>, + pub enum_descriptions: Option>, + #[serde(default)] + pub repeated: bool, + pub minimum: Option, + pub maximum: Option, + #[serde(default)] + pub deprecated: bool, + /// OpenAPI serialization style (form, deepObject, etc.) + #[serde(default)] + pub style: Option, + /// Whether arrays/objects should be exploded into separate params. + #[serde(default)] + pub explode: Option, + /// Lowered `x-fern-availability` for the parameter. `None` is the + /// implicit default (no badge). + #[serde(default)] + pub availability: Option, + /// Optional environment variable that supplies a default value when + /// the corresponding CLI flag is not passed. Populated for synthetic + /// parameters injected by Fern extensions (e.g. idempotency headers); + /// not currently set for spec-declared parameters. + #[serde(default)] + pub env_var: Option, + /// Override the kebab-cased long-flag derived from the parameter's + /// HashMap key. When `Some(_)`, `commands.rs` uses this value + /// verbatim as the `--` instead of running the key through + /// `to_kebab_flag`. The clap arg ID — and the on-the-wire wire-key + /// (e.g. HTTP header name) — still derives from the HashMap key, so + /// the executor's lookup pathway is unchanged. + /// + /// Populated by `inject_idempotency_header_params` so an entry like + /// `{ header: X-Trace-Id, name: trace_id }` surfaces as `--trace-id` + /// (matching the SDK parameter naming the upstream Fern OpenAPI + /// importer produces) while still sending the `X-Trace-Id` header. + #[serde(default)] + pub flag_name_override: Option, + /// Lowered `x-fern-parameter-name` for the parameter. When `Some`, + /// the command builder renames the CLI flag (kebab-cased), while the + /// executor keeps using the original wire name (the map key) for the + /// outgoing HTTP request. Mirrors fern's OpenAPI importer, which uses + /// the alias on the SDK surface but the wire name in the request. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/parameter-name + #[serde(default)] + pub display_name: Option, + /// Lowered `x-fern-enum` per-value overrides. Keyed by the wire + /// value. Entries are only present when the spec opted into the + /// extension; absent → fall back to the raw wire value with no + /// description. + #[serde(default, skip)] + pub fern_enum: Option>, + /// Name of the spec-level `x-fern-sdk-variables` entry that supplies + /// this parameter's value. Set when the parameter carries an + /// `x-fern-sdk-variable: ` extension. Variable-bound path + /// parameters are excluded from the per-operation flag surface; their + /// value is read from the global root flag (or its env-var fallback) + /// and substituted into the path template at request time. + #[serde(default, skip)] + pub variable_reference: Option, +} + +impl MethodParameter { + /// Map a user-supplied value (which may be either the wire value or + /// the `x-fern-enum` display alias) back to the **wire** value the + /// HTTP layer must send. When no override matches, returns the input + /// unchanged so non-enum params and absent extensions are pure + /// identity. + pub fn resolve_enum_display_to_wire<'a>( + &self, + input: &'a str, + ) -> std::borrow::Cow<'a, str> { + let Some(map) = self.fern_enum.as_ref() else { + return std::borrow::Cow::Borrowed(input); + }; + for (wire, entry) in map { + if entry + .display_name + .as_deref() + .is_some_and(|name| name == input) + { + return std::borrow::Cow::Owned(wire.clone()); + } + } + std::borrow::Cow::Borrowed(input) + } +} + +/// Per-value override for `x-fern-enum`. Mirrors the Fern OpenAPI IR +/// importer's `FernEnumConfig` entry — `description` and `name` are the +/// only fields cli-sdk consumes; `casing` is reserved for SDK codegen. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FernEnumValue { + /// User-facing rendered name. When set, surfaces as the canonical + /// option in `--help` while the wire value remains accepted as an + /// alias. + pub display_name: Option, + /// Per-value description rendered in long `--help` output. + pub description: Option, +} + +/// JSON Schema definition for request/response bodies. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct JsonSchema { + pub id: Option, + #[serde(rename = "type")] + pub schema_type: Option, + /// Surfaces both OpenAPI 3.0 `nullable: true` and OpenAPI 3.1 + /// `type: [..., "null"]` uniformly. Lowered by the parser, not the + /// derived deserializer. + #[serde(default)] + pub nullable: bool, + pub description: Option, + #[serde(default)] + pub properties: HashMap, + #[serde(rename = "$ref")] + pub schema_ref: Option, + pub items: Option>, + #[serde(default)] + pub required: Vec, + /// JSON Schema composition branches at the component-schema root. Mirrors + /// the same fields on [`JsonSchemaProperty`] so a top-level union like + /// `Auth0Role: { oneOf: [...] }` is captured, not just composition nested + /// inside a property. Not yet consumed by command generation. + #[serde(default)] + pub one_of: Vec, + #[serde(default)] + pub any_of: Vec, + #[serde(default)] + pub all_of: Vec, + pub additional_properties: Option>, +} + +/// A property within a JSON Schema. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct JsonSchemaProperty { + #[serde(rename = "type")] + pub prop_type: Option, + /// See [`JsonSchema::nullable`]. + #[serde(default)] + pub nullable: bool, + pub description: Option, + #[serde(rename = "$ref")] + pub schema_ref: Option, + pub format: Option, + pub items: Option>, + #[serde(default)] + pub properties: HashMap, + #[serde(default)] + pub read_only: bool, + pub default: Option, + #[serde(rename = "enum")] + pub enum_values: Option>, + /// Inclusive numeric lower bound. Lowered by the parser so the OpenAPI + /// 3.0 / 3.1 `exclusiveMinimum` divergence is resolved before reaching + /// the IR. + pub minimum: Option, + /// Inclusive numeric upper bound. See `minimum` above. + pub maximum: Option, + /// Strict numeric lower bound. Lowered uniformly from both OpenAPI 3.0 + /// (`exclusiveMinimum: true` with paired `minimum`) and 3.1 + /// (`exclusiveMinimum: `). + pub exclusive_minimum: Option, + /// Strict numeric upper bound. See `exclusive_minimum` above. + pub exclusive_maximum: Option, + /// Single example value (OpenAPI 3.0 `example` or 3.1 fallback). + pub example: Option, + /// `examples` block, captured as raw YAML. Real-world specs use this + /// field in three different shapes (3.1 array, lax-3.0 map keyed by + /// example name, single value); the parser preserves all three. + pub examples: Option, + /// JSON Schema composition branches. Lowered by the parser from + /// `oneOf`. Empty when the source had no `oneOf` block. + #[serde(default)] + pub one_of: Vec, + /// JSON Schema composition: `anyOf`. + #[serde(default)] + pub any_of: Vec, + /// JSON Schema composition: `allOf`. + #[serde(default)] + pub all_of: Vec, + pub additional_properties: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_rest_description() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/", + "servicePath": "", + "resources": { + "users": { + "methods": { + "list": { + "httpMethod": "GET", + "path": "/users" + } + } + } + } + }"#; + + let doc: RestDescription = serde_json::from_str(json).unwrap(); + assert_eq!(doc.name, "test"); + assert_eq!(doc.version, "v1"); + + let users = doc.resources.get("users").expect("users resource missing"); + let list = users.methods.get("list").expect("list method missing"); + assert_eq!(list.http_method, "GET"); + } + + #[test] + fn test_deserialize_defaults() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/" + }"#; + + let doc: RestDescription = serde_json::from_str(json).unwrap(); + assert_eq!(doc.service_path, ""); + assert!(doc.resources.is_empty()); + assert!(doc.schemas.is_empty()); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/executor.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/executor.rs new file mode 100644 index 000000000000..2af619a5c605 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/executor.rs @@ -0,0 +1,6847 @@ +//! API Request Execution +//! +//! Handles building and dispatching HTTP requests to APIs. +//! Responsibilities include multipart file uploads, response pagination, +//! and error mapping. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use anyhow::Context; +use futures_util::stream::TryStreamExt; +use futures_util::StreamExt; +use serde_json::{json, Map, Value}; +use tokio::io::AsyncWriteExt; + +use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; +use crate::openapi::discovery::{ + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, +}; + +/// Resolved source for a binary request body (octet-stream uploads etc.). +/// +/// Driven by the value passed on the CLI's binary-body flag (`--file`, `--body`, +/// or whatever name the spec dictates). Accepts three forms: +/// +/// - `` — plain filesystem path. Sent with `Content-Length`. +/// - `@` — same path, curl-style prefix. Sent with `Content-Length`. +/// - `-` — read from stdin. Sent with `Transfer-Encoding: chunked` (no length). +pub enum BinaryBodySource<'a> { + /// Stream from a file on disk. Content-Length comes from file metadata. + File(&'a str), + /// Read from stdin. Body is streamed with chunked transfer encoding. + Stdin, +} + +impl<'a> BinaryBodySource<'a> { + /// Parse a raw flag value into one of the three accepted forms. Stripping + /// the optional `@` prefix happens here so the rest of the pipeline only + /// sees a clean path or `Stdin`. + pub fn parse(raw: &'a str) -> Self { + let stripped = raw.strip_prefix('@').unwrap_or(raw); + if stripped == "-" { + Self::Stdin + } else { + Self::File(stripped) + } + } +} + +/// Source for media upload content. +/// +/// Two mutually exclusive strategies: upload from a file on disk (for Drive, +/// Chat, etc.) or from in-memory bytes (for Gmail's constructed RFC 5322 +/// messages). Using an enum makes illegal states (both set, or mismatched +/// content types) unrepresentable. +pub enum UploadSource<'a> { + /// Stream from a file on disk. Content type is inferred from the file + /// extension, overridden by metadata mimeType, or explicitly set. + File { + path: &'a str, + content_type: Option<&'a str>, + }, + /// Upload from in-memory bytes with an explicit content type. + Bytes { + data: &'a [u8], + content_type: &'a str, + }, +} + +/// Configuration for auto-pagination. +#[derive(Debug, Clone)] +pub struct PaginationConfig { + /// Whether to auto-paginate through all pages. + pub page_all: bool, + /// Maximum number of pages to fetch (default: 10). + pub page_limit: u32, + /// Delay between page fetches in milliseconds (default: 100). + pub page_delay_ms: u64, + /// Query parameter name for the page token (default: "pageToken"). + pub token_query_param: String, + /// Dotted path in JSON response to find the next page token (default: "nextPageToken"). + /// Supports nested paths like "pagination.next_page_token". + pub token_response_path: String, +} + +impl Default for PaginationConfig { + fn default() -> Self { + Self { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + token_query_param: "pageToken".to_string(), + token_response_path: "nextPageToken".to_string(), + } + } +} + +/// Outcome of a single retry-loop iteration. +/// +/// Captures everything the retry policy needs to make its next decision: +/// the HTTP status (or `None` for transport-layer failures), the +/// `Retry-After` header value if any, and the wall-clock timestamp the +/// header should be interpreted against. Keeping the timestamp on the +/// outcome lets unit tests pin `SystemTime::now` to a known instant +/// without monkey-patching the global clock. +#[derive(Debug)] +pub(crate) struct RetryOutcome<'a> { + pub status: Option, + pub retry_after: Option<&'a str>, +} + +/// Returns the default set of retryable HTTP status codes. +/// +/// Matches fern's TypeScript SDK `retryStatusCodes: recommended` mode +/// ([fern PR](https://github.com/fern-api/fern/blob/main/generators/typescript/sdk/changes/3.67.0/feat-retry-status-codes.yml)): +/// +/// - 408 Request Timeout — server gave up before reading; safe. +/// - 429 Too Many Requests — backoff signal; safe. +/// - 502 Bad Gateway — upstream layer failed; transient. +/// - 503 Service Unavailable — explicitly transient by spec. +/// - 504 Gateway Timeout — upstream timeout; transient. +/// +/// Deliberately *excludes* 500 Internal Server Error — a 500 often +/// indicates a non-transient bug on the server (bad input shape, app +/// crash) where retrying just masks the underlying issue. Servers that +/// genuinely want us to retry a 500 can still surface a `Retry-After` +/// header and the executor will honor it. +/// +/// Also excludes 425 Too Early (TLS 1.3 0-RTT replay protection) — +/// never seen in practice from reqwest's HTTP/1.1 client. +pub(crate) fn is_retryable_status(status: u16) -> bool { + matches!(status, 408 | 429 | 502 | 503 | 504) +} + +/// Whether the per-method retry policy allows retrying *non-idempotent* +/// HTTP responses (e.g. a 503 on a POST). GET / HEAD / OPTIONS / DELETE +/// / PUT are idempotent by the HTTP spec; `x-fern-idempotent` on the +/// operation marks an otherwise-unsafe method (POST / PATCH) as +/// safe-to-retry, which mirrors fern's per-method retry policy. +pub(crate) fn method_allows_retry(http_method: &str, marked_idempotent: bool) -> bool { + if marked_idempotent { + return true; + } + matches!( + http_method.to_ascii_uppercase().as_str(), + "GET" | "HEAD" | "OPTIONS" | "DELETE" | "PUT" + ) +} + +/// Whether the given `binary_body_path` raw string designates stdin +/// (`-` or `@-`). Stdin-sourced bodies cannot be replayed on retry — +/// the pipe is consumed by the first send — so callers must disable +/// retries when this returns `true`. Mirrors `BinaryBodySource::parse` +/// without the lifetime gymnastics needed at the call site. +pub(crate) fn binary_body_is_stdin(binary_body_path: Option<&str>) -> bool { + match binary_body_path { + Some(raw) => matches!(BinaryBodySource::parse(raw), BinaryBodySource::Stdin), + None => false, + } +} + +/// Parse a `Retry-After` header value into a `Duration`. +/// +/// HTTP/1.1 allows two forms (RFC 7231 §7.1.3): a non-negative integer +/// number of seconds, or an HTTP-date. We accept either. Past dates +/// (the server's clock is ahead, or `Retry-After: 0`) collapse to +/// zero so callers don't underflow. +pub(crate) fn parse_retry_after(value: &str, now: std::time::SystemTime) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + // Numeric seconds first — cheaper and far more common in practice. + if let Ok(secs) = trimmed.parse::() { + return Some(std::time::Duration::from_secs(secs)); + } + // HTTP-date (IMF-fixdate / RFC 850 / asctime). `httpdate::parse_http_date` + // accepts all three formats per RFC 7231. + if let Ok(target) = httpdate::parse_http_date(trimmed) { + return Some(target.duration_since(now).unwrap_or(std::time::Duration::ZERO)); + } + None +} + +/// Compute the delay for the *next* retry attempt (i.e. the wait +/// between attempt `attempt` and attempt `attempt + 1`). +/// +/// Math: `base * factor^attempt`, with deterministic jitter in +/// `[1 - jitter/2, 1 + jitter/2]` applied to the result. The jitter +/// factor is sampled from a fast LCG so test runs are deterministic +/// when seeded — see [`compute_backoff_delay_with_rand`] below. +pub(crate) fn compute_backoff_delay( + attempt: u32, + config: &RetriesConfig, +) -> std::time::Duration { + // Use system entropy for the random sample. Unit tests use + // `compute_backoff_delay_with_rand` to pin the sample. + let jitter_sample = if config.jitter > 0.0 { + // Sub-second component of wall-clock time as cheap entropy. + // We don't care about cryptographic quality here — just enough + // variance to de-correlate retries from competing clients + // (i.e. avoid the thundering-herd problem during an outage). + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as u64; + ((nanos.wrapping_mul(2654435761)) & 0xFFFF) as f64 / 65535.0 + } else { + 0.5 + }; + compute_backoff_delay_with_rand(attempt, config, jitter_sample) +} + +/// Test-friendly variant of [`compute_backoff_delay`]. `rand_unit` is +/// any value in `[0.0, 1.0]`; pass `0.5` for the "exact" backoff +/// (no jitter offset). +pub(crate) fn compute_backoff_delay_with_rand( + attempt: u32, + config: &RetriesConfig, + rand_unit: f64, +) -> std::time::Duration { + if !config.enabled { + return std::time::Duration::ZERO; + } + let exponent = attempt as i32; + let raw_ms = (config.base_delay_ms as f64) * config.factor.powi(exponent); + + // Jitter spreads the delay symmetrically around `raw_ms` to + // de-correlate clients all retrying off the same server outage. + let jitter_span = raw_ms * config.jitter; + let offset = (rand_unit.clamp(0.0, 1.0) - 0.5) * jitter_span; + let ms = (raw_ms + offset).max(0.0); + + // Cap at u64 to avoid panics on absurd configs (e.g. factor=1e9). + let capped = if ms > u64::MAX as f64 { + u64::MAX + } else { + ms as u64 + }; + std::time::Duration::from_millis(capped) +} + +/// Decide whether to retry after an HTTP outcome. +/// +/// Returns `Some(delay)` to schedule a retry, or `None` to surface the +/// outcome to the caller. Encapsulates the precedence rules in one +/// place so the wire executor stays a thin loop body. +pub(crate) fn decide_retry( + attempt: u32, + outcome: &RetryOutcome<'_>, + config: &RetriesConfig, + http_method: &str, + marked_idempotent: bool, + no_retry: bool, +) -> Option { + // Hard opt-outs first. + if no_retry || !config.enabled || config.max_attempts == 0 { + return None; + } + // attempt is 0-indexed (the request just completed was attempt + // `attempt`); we retry while we still have room before + // `max_attempts` total sends. + if attempt + 1 >= config.max_attempts { + return None; + } + + match outcome.status { + // Network / transport failure (no response at all). + None => { + // Network errors are always treated as transient. GET-like + // methods retry per default; POST/PATCH only when the + // operation is explicitly marked idempotent. + if !method_allows_retry(http_method, marked_idempotent) { + return None; + } + Some(compute_backoff_delay(attempt, config)) + } + Some(status) => { + if !is_retryable_status(status) { + return None; + } + // 408/429 are safe to retry on any method (the request + // didn't reach business logic). 5xx on non-idempotent + // methods *could* have been processed — respect per-method + // policy unless the op is marked idempotent. + let always_safe = matches!(status, 408 | 429); + if !always_safe && !method_allows_retry(http_method, marked_idempotent) { + return None; + } + // Honor `Retry-After` when present, fall back to backoff. + if let Some(raw) = outcome.retry_after { + if let Some(d) = parse_retry_after(raw, std::time::SystemTime::now()) { + return Some(d); + } + } + Some(compute_backoff_delay(attempt, config)) + } + } +} + +/// Parsed and validated inputs ready for request execution. +#[derive(Debug)] +struct ExecutionInput { + body: Option, + full_url: String, + query_params: Vec<(String, String)>, + header_params: Vec<(String, String)>, + is_upload: bool, +} + +/// Parse parameters and body JSON, validate against schema, check required params, and build the URL. +fn parse_and_validate_inputs( + doc: &RestDescription, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + is_media_upload: bool, + base_url_override: Option<&str>, + extra_headers: &[(String, String)], +) -> Result { + let params: Map = if let Some(p) = params_json { + serde_json::from_str(p) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))? + } else { + Map::new() + }; + + // Helper: build the `Provide it via …` hint listing every channel a + // user can satisfy this parameter through. Mirrors `commands.rs`'s + // flag-name resolution so the suggested `--` is the actual flag + // the user can pass: `flag_name_override` wins verbatim (synthetic + // injections that already encode the wire name); otherwise kebab the + // `display_name` from `x-fern-parameter-name`, falling back to the + // wire name. Body fields also accept `--json`; every other location + // only accepts the per-field flag or `--params`. + let missing_param_hint = |param_def: &MethodParameter, param_name: &str| -> String { + let flag = if let Some(override_flag) = param_def.flag_name_override.as_deref() { + override_flag.to_string() + } else { + crate::text::to_kebab_flag( + param_def.display_name.as_deref().unwrap_or(param_name), + ) + }; + if param_def.location.as_deref() == Some("body") { + format!("Provide it via --{flag}, --json, or --params") + } else { + format!("Provide it via --{flag} or --params") + } + }; + + for param_name in &method.parameter_order { + if let Some(param_def) = method.parameters.get(param_name) { + if param_def.required + && param_def.location.as_deref() == Some("path") + && !params.contains_key(param_name) + { + let hint = missing_param_hint(param_def, param_name); + return Err(CliError::Validation(format!( + "Required path parameter '{param_name}' is missing. {hint}" + ))); + } + } + } + + for (param_name, param_def) in &method.parameters { + if param_def.required && !params.contains_key(param_name) { + // When --json is provided, body-located required params are satisfied + // by the JSON payload — skip their individual-flag validation. + if param_def.location.as_deref() == Some("body") && body_json.is_some() { + continue; + } + let hint = missing_param_hint(param_def, param_name); + return Err(CliError::Validation(format!( + "Required parameter '{param_name}' is missing. {hint}" + ))); + } + } + + // Split params by `location` into header / body / non-header buckets. + // Body-located params are coerced by type and merged into the JSON body + // (with --json overriding any individual flag values). + let mut header_params: Vec<(String, String)> = Vec::new(); + let mut body_from_flags = Map::new(); + let mut non_header_params = Map::new(); + + for (key, value) in ¶ms { + let location = method.parameters.get(key).and_then(|p| p.location.as_deref()); + match location { + Some("header") => { + let str_value = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + header_params.push((key.clone(), str_value)); + } + Some("body") => { + let coerced = coerce_body_param_value( + value, + method.parameters.get(key).and_then(|p| p.param_type.as_deref()), + )?; + set_nested_value(&mut body_from_flags, key, coerced); + } + _ => { + non_header_params.insert(key.clone(), value.clone()); + } + } + } + + // Append spec-root `x-fern-global-headers` last so per-operation + // headers (already populated above from `params`) override globals + // with the same wire-name. Resolution of CLI flag / env / default + // happens upstream in `run_async`; the executor's job here is just + // to stamp the resolved value on the request when no per-op + // parameter already supplied it. + for (name, value) in extra_headers { + if !header_params.iter().any(|(k, _)| k == name) { + header_params.push((name.clone(), value.clone())); + } + } + + let body: Option = match (body_json, body_from_flags.is_empty()) { + (None, true) => None, + (None, false) => Some(Value::Object(body_from_flags)), + (Some(b), flags_empty) => { + let json_val: Value = serde_json::from_str(b) + .map_err(|e| CliError::Validation(format!("Invalid --json body: {e}")))?; + // Object `--json` merges per-field flag values, with `--json` + // winning on overlapping keys (documented "--json > flag" + // precedence). Non-object `--json` (top-level array or scalar) + // replaces the body wholesale — there is no sensible way to + // merge per-field fields into it. The full precedence chain is + // `--json` > `--params` > per-field flag. + let merged = match json_val { + Value::Object(json_map) if !flags_empty => { + let mut merged = body_from_flags; + for (k, v) in json_map { + merged.insert(k, v); + } + Value::Object(merged) + } + other => other, + }; + Some(merged) + } + }; + + // Validate the assembled body against the request schema regardless of + // how it was built (per-field flags, `--json`, or both). The previous + // version only validated on the `--json` path, which let per-field-flag + // bodies skip schema checks even though those values arrive as + // CLI-typed strings and are more likely to violate the schema. + if let Some(ref body_val) = body { + if let Some(ref req_ref) = method.request { + if let Some(ref schema_name) = req_ref.schema_ref { + validate_body_against_schema(body_val, schema_name, doc)?; + } + } + } + + let (full_url, query_params) = build_url(doc, method, &non_header_params, is_media_upload, base_url_override)?; + let is_upload = is_media_upload && method.supports_media_upload; + + Ok(ExecutionInput { + body, + full_url, + query_params, + header_params, + is_upload, + }) +} + +/// Build the per-operation auth metadata from the lowered security +/// requirements. Computed once per execute_method call and reused across +/// pagination iterations — the requirements don't change page to page. +fn endpoint_metadata_for(method: &RestMethod) -> EndpointAuthMetadata { + EndpointAuthMetadata { + security_requirements: method.security_requirements.clone(), + } +} + +/// Pagination loop state tracked across page fetches. +/// +/// Each variant matches one of the five `x-fern-pagination` forms, plus +/// the document-level heuristic (which uses [`PageState::Cursor`]): +/// +/// - [`PageState::Cursor`] — token threaded through a request query param +/// - [`PageState::Offset`] — running offset counter sent as a query param +/// - [`PageState::NextUrl`] — server-returned absolute URL (uri form) or +/// resolved relative path (path form) used verbatim for the next page +/// - [`PageState::Custom`] — single-shot; the executor never continues +/// +/// Encoded as a discriminated union rather than several `Option`s so that +/// callers can't accidentally mix semantics from different forms. +#[derive(Debug)] +enum PageState { + Cursor(Option), + Offset(u64), + /// `None` on the first page, `Some(url)` once the previous response + /// supplied a next URL/path. The string is always a fully-qualified + /// URL — relative `next_path` values are resolved against the + /// previous request's URL before being stored here. + NextUrl(Option), + Custom, +} + +impl PageState { + /// Pick the initial state from the resolved per-operation pagination + /// config. Operations without explicit `x-fern-pagination` (or with + /// cursor-style config) start with no token; offset-style starts at + /// 0; uri/path/custom forms start in their respective first-page + /// states. + fn initial(endpoint: Option<&EndpointPagination>) -> Self { + match endpoint { + Some(EndpointPagination::Offset { .. }) => PageState::Offset(0), + Some(EndpointPagination::Uri { .. } | EndpointPagination::Path { .. }) => { + PageState::NextUrl(None) + } + Some(EndpointPagination::Custom { .. }) => PageState::Custom, + // Cursor + heuristic + None all use the cursor-style state. + _ => PageState::Cursor(None), + } + } + + /// Override the outgoing URL when the pagination form does so (uri / + /// path). `None` means leave the request's URL untouched. + fn url_override(&self) -> Option<&str> { + match self { + PageState::NextUrl(Some(url)) => Some(url.as_str()), + _ => None, + } + } + + /// Convert the state into the (query-param name, value) pair to inject + /// on the next outgoing request, or `None` when the state represents + /// "first page, no extra param yet" or "URL is fully self-contained". + fn injection( + &self, + endpoint: Option<&EndpointPagination>, + heuristic_param: &str, + ) -> Option<(String, String)> { + match self { + PageState::Cursor(None) => None, + PageState::Cursor(Some(token)) => { + let name = match endpoint { + Some(EndpointPagination::Cursor { cursor, .. }) => cursor.clone(), + _ => heuristic_param.to_string(), + }; + Some((name, token.clone())) + } + PageState::Offset(0) => None, + PageState::Offset(n) => { + let name = match endpoint { + Some(EndpointPagination::Offset { offset, .. }) => offset.clone(), + _ => heuristic_param.to_string(), + }; + Some((name, n.to_string())) + } + // Uri / Path embed the cursor in the URL itself. + PageState::NextUrl(_) | PageState::Custom => None, + } + } +} + +/// Build an HTTP request with auth, query params, page token, and body/multipart attachment. +#[allow(clippy::too_many_arguments)] +async fn build_http_request( + client: &reqwest::Client, + method: &RestMethod, + input: &ExecutionInput, + auth_provider: &DynAuthProvider, + auth_metadata: &EndpointAuthMetadata, + page_state: &PageState, + pages_fetched: u32, + upload: &Option>, + binary_body_path: Option<&str>, + pagination: &PaginationConfig, +) -> Result { + // Uri / Path pagination supplies a fully-resolved next URL in the + // page state; use it verbatim so that the server's cursor / query + // params travel as-is. + let target_url = page_state.url_override().unwrap_or(&input.full_url); + + let mut request = match method.http_method.as_str() { + "GET" => client.get(target_url), + "POST" => client.post(target_url), + "PUT" => client.put(target_url), + "PATCH" => client.patch(target_url), + "DELETE" => client.delete(target_url), + other => { + return Err(CliError::Other(anyhow::anyhow!( + "Unsupported HTTP method: {other}" + ))) + } + }; + + // `security: []` in the spec means the operation opts out of auth. + // Short-circuit before involving the provider so leaf providers + // (Bearer/Basic/Header) and composition wrappers that don't inspect + // the endpoint (AnyAuthProvider, AllAuthProvider, user-built custom + // providers) can't leak credentials onto an explicitly anonymous + // endpoint. RoutingAuthProvider already honors this internally; the + // executor-side check makes it universal. + if !auth_metadata.is_explicit_anonymous() { + request = auth_provider.apply(request, auth_metadata)?; + } + + // Prefer JSON when the API supports content negotiation (some providers + // return XML otherwise). Only inject when the operation doesn't already + // set an Accept header. + if !input + .header_params + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("accept")) + { + request = request.header("Accept", "application/json"); + } + + // Send header parameters as HTTP headers + for (name, value) in &input.header_params { + if let Ok(header_value) = reqwest::header::HeaderValue::from_str(value) { + request = request.header(name.as_str(), header_value); + } + } + + // When the URL is supplied by the server (uri / path pagination) + // the URL already carries every query param the server cares about + // — re-appending the user's initial filters would either double them + // up or fight the server's own cursor. Honor the server's URL as-is. + if page_state.url_override().is_none() { + let mut all_query_params = input.query_params.clone(); + if let Some((name, value)) = + page_state.injection(method.pagination.as_ref(), &pagination.token_query_param) + { + all_query_params.push((name, value)); + } + if !all_query_params.is_empty() { + request = request.query(&all_query_params); + } + } + + if pages_fetched == 0 { + if let Some(upload_source) = upload { + request = request.query(&[("uploadType", "multipart")]); + let (body, content_type, content_length) = match upload_source { + UploadSource::Bytes { data, content_type } => { + if content_type.contains('\r') || content_type.contains('\n') { + return Err(CliError::Validation( + "Upload content type must not contain CR or LF".to_string(), + )); + } + build_multipart_bytes(&input.body, data, content_type)? + } + UploadSource::File { path, content_type } => { + let file_meta = tokio::fs::metadata(path).await.map_err(|e| { + CliError::Validation(format!( + "Failed to get metadata for upload file '{path}': {e}" + )) + })?; + let file_size = file_meta.len(); + let media_mime = resolve_upload_mime(*content_type, Some(path), &input.body); + build_multipart_stream(&input.body, path, file_size, &media_mime)? + } + }; + request = request.header("Content-Type", content_type); + request = request.header("Content-Length", content_length); + request = request.body(body); + } else if let Some(raw) = binary_body_path { + let binary = method.binary_request_body.as_ref().ok_or_else(|| { + CliError::Validation( + "binary body path was provided but the operation has no binary request body declared" + .to_string(), + ) + })?; + request = request.header("Content-Type", &binary.content_type); + match BinaryBodySource::parse(raw) { + BinaryBodySource::File(path) => { + let file_meta = tokio::fs::metadata(path).await.map_err(|e| { + CliError::Validation(format!( + "Failed to read --{} '{path}': {e}", + binary.flag_name + )) + })?; + let (body, content_length) = + build_binary_file_stream(path, file_meta.len(), &binary.flag_name); + request = request.header("Content-Length", content_length); + request = request.body(body); + } + BinaryBodySource::Stdin => { + // No Content-Length — reqwest emits Transfer-Encoding: chunked. + // Memory stays at O(64 KB) regardless of input size. + request = request.body(build_stdin_body_stream()); + } + } + } else if let Some(ref body_val) = input.body { + request = encode_request_body(request, body_val, &method.body_encoding); + } else if matches!(method.http_method.as_str(), "POST" | "PUT" | "PATCH") { + request = request.header("Content-Length", "0"); + } + } else if let Some(ref body_val) = input.body { + request = encode_request_body(request, body_val, &method.body_encoding); + } + + Ok(request) +} + +/// Walk a dotted path like "pagination.next_page_token" through nested JSON objects. +fn get_nested_str<'a>(val: &'a Value, dotted_path: &str) -> Option<&'a str> { + let mut current = val; + for segment in dotted_path.split('.') { + current = current.get(segment)?; + } + current.as_str() +} + +/// Resolve a dot-separated path (`data`, `result.items`, `users.0.name`) +/// against a JSON value, returning a reference to the addressed subvalue. +/// +/// Empty / pure-dot paths are treated as "no path" and return `None` so the +/// caller can decide between "use the whole value" and "this is an error". +/// A non-empty path that doesn't resolve also returns `None` — callers +/// that need to surface a user-facing error (like +/// `x-fern-sdk-return-value` extraction) check for that case and emit a +/// `CliError::Validation` explaining which path missed. +/// +/// Segments are matched against object keys (`Value::get(&str)`); a +/// segment that parses as a non-negative integer additionally indexes +/// into arrays at the corresponding position (`Value::get(usize)`). +/// Object-key lookup wins when the same segment is ambiguous — JSON +/// object keys can be the literal string `"0"`, and surfacing the +/// matching key is what a user reading the spec expects. Falling back +/// to array indexing only when the value is actually an array keeps +/// the dot-path grammar a strict superset of upstream's +/// `RESPONSE_PROPERTY` (object-only) behavior. +fn get_nested_value<'a>(val: &'a Value, dotted_path: &str) -> Option<&'a Value> { + let trimmed = dotted_path.trim(); + if trimmed.is_empty() { + return None; + } + let mut current = val; + for segment in trimmed.split('.') { + if segment.is_empty() { + return None; + } + // Object-key lookup first, then numeric-array-index fallback so + // an object with a literal `"0"` key still resolves there. The + // array path only triggers when the current value is actually + // a JSON array — otherwise the segment was meant as an object + // key and was simply missing, which the `?` propagates. + if let Some(next) = current.get(segment) { + current = next; + continue; + } + if current.is_array() { + if let Ok(idx) = segment.parse::() { + current = current.get(idx)?; + continue; + } + } + return None; + } + Some(current) +} + +/// Apply `x-fern-sdk-return-value` extraction to a single response value. +/// +/// `return_path` is the dot-separated key path declared by the spec (e.g. +/// `data`, `result.items`). When the path resolves, the addressed subvalue +/// is returned for downstream printing / capture. A non-empty path that +/// resolves to JSON `null` is preserved as `Value::Null` (the field was +/// in the response, just null — typed SDKs surface this identically). +/// A path that fails to resolve *at all* (missing key, intermediate +/// non-object, out-of-range index) is a hard error — the spec promised +/// that subvalue and the server didn't deliver it. `no_extract = true` +/// bypasses the extraction entirely so callers (typically via +/// `--no-extract`) can see the full response for debugging. +/// +/// TODO(error-variant): `CliError::Validation` is the closest existing +/// variant but conceptually this is *response-contract* validation, not +/// input validation. Worth introducing a `CliError::ResponseContract` +/// variant once another response-side validation error needs the same +/// classification. +fn extract_return_value( + body: &Value, + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + match return_path { + Some(path) if !no_extract && !path.trim().is_empty() => { + match get_nested_value(body, path) { + Some(v) => Ok(v.clone()), + None => Err(CliError::Validation(format!( + "x-fern-sdk-return-value path '{path}' did not resolve in response for \ + operation {method_descriptor}. Pass --no-extract to see the full response." + ))), + } + } + _ => Ok(body.clone()), + } +} + +/// Resolve the offset-pagination `step` value used for the +/// "did we get a full page?" check that gates pagination on short pages. +/// +/// `step_field` is the post-prefix-stripped field name from the spec (e.g. +/// `step: $request.limit` becomes `"limit"`). Resolution order: +/// +/// 1. Look up the field name in the request's outgoing query params and +/// parse the value as an integer — the canonical `$request.` +/// interpretation, matching fern-api/fern's SDK generators. +/// 2. If the field is itself a parseable integer literal (e.g. `step: "50"`), +/// use that. +/// 3. Otherwise return `None` — the caller falls back to the legacy +/// `items.len() > 0` check. +/// +/// Mirrors upstream `fern-api/fern`'s SDK generators: the step value is +/// used **only** for the `hasNextPage` full-page comparison +/// (`items.length >= step`) — never as the increment amount. The increment +/// is always `len(items)` in item-index semantics, which is what the +/// executor's offset loop already does. See: +/// - `generators/python/.../client_generator/pagination/offset.py` +/// - `generators/typescript/.../GeneratedThrowingEndpointResponse.ts` +fn resolve_step_target( + step_field: Option<&str>, + request_query_params: &[(String, String)], +) -> Option { + let name = step_field?; + if let Some((_, value)) = request_query_params.iter().find(|(k, _)| k == name) { + if let Ok(parsed) = value.parse::() { + return Some(parsed); + } + } + name.parse::().ok() +} + +/// Resolve a `next_path` value from `x-fern-pagination` against the URL of +/// the request that produced it. Mirrors browser-style URL resolution: +/// absolute URLs (`https://…`) replace the base; absolute paths (`/foo`) +/// keep the scheme + host; relative paths inherit the base's directory. +fn resolve_next_path(base_url: &str, next_path: &str) -> Result { + let base = reqwest::Url::parse(base_url) + .map_err(|e| format!("base URL `{base_url}` is not a valid URL: {e}"))?; + let resolved = base + .join(next_path) + .map_err(|e| format!("could not join next_path `{next_path}` to `{base_url}`: {e}"))?; + Ok(resolved.to_string()) +} + +/// Handle a JSON response: parse, output, and check pagination. +/// Returns `Ok(true)` if the pagination loop should continue. +/// +/// `return_path` is the operation's resolved `x-fern-sdk-return-value` +/// extension (a dot-separated key path into the JSON body). When set and +/// `no_extract` is false, only the addressed subvalue is printed / +/// captured — but the full response is still used for pagination +/// continuation checks, since pagination paths (`next_cursor`, +/// `results`, …) are declared relative to the whole body and would +/// silently break if extracted away. +#[allow(clippy::too_many_arguments)] +async fn handle_json_response( + body_text: &str, + pagination: &PaginationConfig, + endpoint_pag: Option<&EndpointPagination>, + pipeline: &crate::formatter::OutputPipeline, + pages_fetched: &mut u32, + page_state: &mut PageState, + capture_output: bool, + captured: &mut Vec, + request_url: &str, + request_query_params: &[(String, String)], + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + if let Ok(json_val) = serde_json::from_str::(body_text) { + let output_val = + extract_return_value(&json_val, return_path, no_extract, method_descriptor)?; + + *pages_fetched += 1; + + // The three branches below are mutually exclusive (one consumes + // `output_val`), so the unconditional move into `captured.push` + // is safe. If a future change adds a side-effect that also + // needs `output_val` outside this if/else chain, the compiler + // will flag it — clone there rather than reintroducing a + // speculative `.clone()` here. + if capture_output { + captured.push(output_val); + } else if pagination.page_all { + let is_first_page = *pages_fetched == 1; + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &output_val, true, is_first_page) + .context("Failed to write output")?; + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &output_val, false, true) + .context("Failed to write output")?; + } + + // Check whether to fetch a next page. Per-op `x-fern-pagination` + // overrides the document heuristic when present. + if pagination.page_all && *pages_fetched < pagination.page_limit { + let should_continue = match endpoint_pag { + Some(EndpointPagination::Cursor { next_cursor, .. }) => { + match get_nested_str(&json_val, next_cursor) { + Some(token) if !token.is_empty() => { + *page_state = PageState::Cursor(Some(token.to_string())); + true + } + _ => false, + } + } + Some(EndpointPagination::Offset { + results, + has_next_page, + step, + .. + }) => { + let still_more = match has_next_page { + Some(path) => json_val + .pointer(&format!("/{}", path.replace('.', "/"))) + .and_then(Value::as_bool) + .unwrap_or(true), + None => true, + }; + let page_size = json_val + .pointer(&format!("/{}", results.replace('.', "/"))) + .and_then(Value::as_array) + .map(|a| a.len() as u64) + .unwrap_or(0); + // When `step` is wired, gate the next page on whether + // the server returned a *full* page. Matches upstream + // fern-api/fern's `items.length >= step` check — a + // server returning a short page signals end-of-data + // even if `has_next_page` was omitted, preventing the + // executor from over-advancing past the last record. + let got_full_page = + match resolve_step_target(step.as_deref(), request_query_params) { + Some(target) => page_size >= target, + None => page_size > 0, + }; + if still_more && got_full_page { + let current = match page_state { + PageState::Offset(n) => *n, + _ => 0, + }; + // Advance by the number of items actually returned + // — item-index semantics, matching upstream's + // default `offsetSemantics`. The `step` field + // controls only the full-page gate above, not the + // increment amount. + *page_state = PageState::Offset(current + page_size); + true + } else { + false + } + } + Some(EndpointPagination::Uri { next_uri, .. }) => { + match get_nested_str(&json_val, next_uri) { + Some(url) if !url.is_empty() => { + *page_state = PageState::NextUrl(Some(url.to_string())); + true + } + _ => false, + } + } + Some(EndpointPagination::Path { next_path, .. }) => { + match get_nested_str(&json_val, next_path) { + Some(path) if !path.is_empty() => { + // Resolve relative paths (e.g. `/v1/things?cursor=…`) + // against the previous request's URL so the host + // + scheme are preserved across pages. + let base = page_state + .url_override() + .unwrap_or(request_url) + .to_string(); + match resolve_next_path(&base, path) { + Ok(resolved) => { + *page_state = PageState::NextUrl(Some(resolved)); + true + } + Err(e) => { + tracing::warn!( + next_path = %path, + base_url = %base, + error = %e, + "failed to resolve x-fern-pagination next_path; halting pagination" + ); + false + } + } + } + _ => false, + } + } + // Custom: caller-driven. The executor never auto-continues; + // it issues exactly one request, surfaces the `results` + // selection like the others, and stops. + Some(EndpointPagination::Custom { .. }) => false, + None => match get_nested_str(&json_val, &pagination.token_response_path) { + Some(token) if !token.is_empty() => { + *page_state = PageState::Cursor(Some(token.to_string())); + true + } + _ => false, + }, + }; + + if should_continue { + if pagination.page_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis( + pagination.page_delay_ms, + )) + .await; + } + return Ok(true); + } + } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); + } + + Ok(false) +} + +/// Handle a binary response by streaming it to a file. +async fn handle_binary_response( + response: reqwest::Response, + content_type: &str, + output_path: Option<&str>, + pipeline: &crate::formatter::OutputPipeline, + capture_output: bool, +) -> Result, CliError> { + let file_path = if let Some(p) = output_path { + PathBuf::from(p) + } else { + let ext = mime_to_extension(content_type); + PathBuf::from(format!("download.{ext}")) + }; + + let mut file = tokio::fs::File::create(&file_path) + .await + .context("Failed to create output file")?; + + let mut stream = response.bytes_stream(); + let mut total_bytes: u64 = 0; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Failed to read response chunk")?; + file.write_all(&chunk) + .await + .context("Failed to write to file")?; + total_bytes += chunk.len() as u64; + } + + file.flush().await.context("Failed to flush file")?; + + let result = json!({ + "status": "success", + "saved_file": file_path.display().to_string(), + "mimeType": content_type, + "bytes": total_bytes, + }); + + if capture_output { + return Ok(Some(result)); + } + + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &result, false, true) + .context("Failed to write output")?; + + Ok(None) +} + +// --------------------------------------------------------------------------- +// x-fern-streaming response handling. +// +// Two entry points: +// - `stream_response` — consume the response body line-by-line and emit +// each event to stdout as it arrives. Used by the default CLI path +// (no `--no-stream`, not `capture_output`). +// - `buffer_streaming_response` — collect every event into one JSON value +// (single object when only one event arrived, array otherwise) and +// return it for downstream printing / capture. Used when the caller +// passed `--no-stream` (pretty-print to stdout) or is a programmatic +// `AppContext::invoke` caller that needs a typed value back. +// +// Line decoding is delegated to `decode_stream_event`, which is a pure +// function over (config, raw_line) — exercised directly by unit tests +// without spinning up a wiremock server. +// --------------------------------------------------------------------------- + +/// Outcome of decoding a single raw stream line. +#[derive(Debug, PartialEq, Eq)] +enum StreamEvent { + /// A complete event payload was decoded (post-`data:` strip for + /// SSE; the line verbatim for NDJSON). The caller emits this. + Event(String), + /// The line was framing-only and carries no payload (blank lines, + /// `event:`/`id:`/`retry:` SSE field lines, SSE comments starting + /// with `:`, or an empty JSON line). Skip and keep reading. + Skip, + /// The terminator sentinel was reached. The caller stops reading. + Terminate, +} + +/// Decode a single raw line of streaming response body against the +/// configured wire format. Pure / synchronous so unit tests can hit +/// every decoding branch (with and without `data:` prefix, comment +/// lines, terminator handling) without setting up a mock HTTP server. +/// +/// The `line` is expected to already have its trailing newline / CR +/// stripped — the caller (the line-reading loop) handles framing. +/// +/// Only the line-at-a-time formats (NDJSON, text) flow through here. +/// SSE framing is stateful (multi-line `data:` payloads are joined +/// with `\n` and dispatched on a blank-line separator per the WHATWG +/// spec), so the SSE path uses [`SseLineDecoder`] instead. +fn decode_stream_event(config: &StreamingConfig, line: &str) -> StreamEvent { + match config { + StreamingConfig::Sse { .. } => { + // SSE is decoded statefully via `SseLineDecoder`; reaching + // this arm is a bug in the caller. + debug_assert!(false, "SSE lines must flow through SseLineDecoder"); + StreamEvent::Skip + } + StreamingConfig::Json { terminator } => { + // NDJSON / JSONL framing: empty lines are skipped (some + // servers emit blank keepalive lines between records). + if line.is_empty() { + return StreamEvent::Skip; + } + + if let Some(sentinel) = terminator.as_deref() { + if line == sentinel { + return StreamEvent::Terminate; + } + } + + StreamEvent::Event(line.to_string()) + } + StreamingConfig::Text => { + // Plain-text line stream: empty lines are dropped per the + // C# generator (`if(!string.IsNullOrEmpty(line)) yield + // return line` — see `HttpEndpointGenerator.ts:815-825`). + // No JSON parse, no SSE prefix strip, no terminator. + if line.is_empty() { + return StreamEvent::Skip; + } + StreamEvent::Event(line.to_string()) + } + } +} + +/// Stateful SSE event accumulator. Buffers `data:` payloads across +/// multiple lines (joined with `\n` per the WHATWG SSE spec +/// ) +/// and dispatches the joined payload as one event on a blank-line +/// separator or at stream EOF. Mirrors the TS runtime's +/// `iterSseEvents` loop in +/// `generators/typescript/utils/core-utilities/src/core/stream/Stream.template.ts:123-165`. +/// +/// Unknown SSE field lines (`id:`, `retry:`, or anything else) are +/// ignored per spec; `event:` is tracked across the same event +/// boundary for parity even though the CLI surface does not yet +/// route on it (no `eventDiscriminator` support — a deliberate +/// non-feature, left out of this parity sweep). +#[derive(Default)] +struct SseLineDecoder { + data_buf: Option, + event_type: Option, +} + +impl SseLineDecoder { + /// Process one raw line. Returns `Some(payload)` when a blank + /// line dispatches a buffered event (the joined `data:` + /// payload); `None` otherwise. + fn push_line(&mut self, line: &str) -> Option { + if line.is_empty() { + // Blank line: dispatch the buffered event if any, then + // reset event_type either way (matches TS, which clears + // both fields on dispatch regardless of whether one was + // actually emitted). + let dispatched = self.data_buf.take(); + self.event_type = None; + return dispatched; + } + if line.starts_with(':') { + // SSE comment / heartbeat — framing only, no payload. + return None; + } + if let Some(rest) = line.strip_prefix("event:") { + self.event_type = Some(rest.trim().to_string()); + return None; + } + if let Some(rest) = line.strip_prefix("data:") { + // Strip exactly one optional leading space per the SSE + // spec ("If value starts with a U+0020 SPACE, remove it"). + let val = rest.strip_prefix(' ').unwrap_or(rest); + match &mut self.data_buf { + Some(buf) => { + buf.push('\n'); + buf.push_str(val); + } + None => { + self.data_buf = Some(val.to_string()); + } + } + return None; + } + // Unknown SSE fields (`id:`, `retry:`, anything else) are + // ignored per spec. + None + } + + /// Flush the final partial event at stream EOF. Mirrors the TS + /// runtime's post-loop `if (dataValue != null) yield ...` block + /// — servers commonly close the connection without a trailing + /// blank line on the last event. + fn flush(&mut self) -> Option { + let dispatched = self.data_buf.take(); + self.event_type = None; + dispatched + } +} + +/// Apply `x-fern-sdk-return-value` to a decoded event payload. Each +/// event is parsed as JSON, the configured path is projected, and the +/// printable form (a JSON-encoded string) is returned. When the JSON +/// fails to parse (servers occasionally emit a non-JSON keepalive +/// frame), the raw event string is emitted verbatim so the caller can +/// still see what came over the wire. +/// +/// Text streams ([`StreamingConfig::Text`]) bypass this projection +/// entirely — their event payload is a raw line, not a JSON value, +/// so `x-fern-sdk-return-value` and `--no-extract` are both no-ops +/// (mirrors the C# generator, which `yield return line` directly). +fn project_stream_event( + streaming: &StreamingConfig, + event_payload: &str, + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + if matches!(streaming, StreamingConfig::Text) { + return Ok(Value::String(event_payload.to_string())); + } + match serde_json::from_str::(event_payload) { + Ok(parsed) => extract_return_value(&parsed, return_path, no_extract, method_descriptor), + // Bare strings, numbers, or partial frames flow through as + // strings so the caller's output stream isn't blocked by + // upstream noise. The user can `--no-extract` to inspect the + // raw frames when debugging unexpected shapes. + Err(_) => Ok(Value::String(event_payload.to_string())), + } +} + +/// Stream the response body line-by-line, emitting one formatted event +/// per dispatched payload to stdout. Stops at the configured +/// terminator (when the spec declared one) or at end-of-body. +async fn stream_response( + response: reqwest::Response, + streaming: &StreamingConfig, + return_path: Option<&str>, + no_extract: bool, + pipeline: &crate::formatter::OutputPipeline, + method_descriptor: &str, +) -> Result<(), CliError> { + read_stream_events(response, streaming, |payload| { + let value = project_stream_event( + streaming, + &payload, + return_path, + no_extract, + method_descriptor, + )?; + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &value, false, true) + .context("Failed to write output")?; + Ok(()) + }) + .await +} + +/// Buffer the streaming response into a single JSON value: a lone event +/// is returned as-is so downstream consumers see the unary shape; two +/// or more events are collected into a JSON array. An empty stream +/// returns `Value::Null` — the body finished without emitting any +/// payload, which is what the typed SDKs surface back to callers. +async fn buffer_streaming_response( + response: reqwest::Response, + streaming: &StreamingConfig, + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + let mut events: Vec = Vec::new(); + read_stream_events(response, streaming, |payload| { + events.push(project_stream_event( + streaming, + &payload, + return_path, + no_extract, + method_descriptor, + )?); + Ok(()) + }) + .await?; + Ok(match events.len() { + 0 => Value::Null, + 1 => events.into_iter().next().unwrap(), + _ => Value::Array(events), + }) +} + +/// Drive the response body through the format-appropriate line +/// decoder, invoking `emit` for each dispatched event payload. SSE +/// uses [`SseLineDecoder`] (stateful multi-line `data:` buffering); +/// NDJSON and text use [`decode_stream_event`] line-by-line. The +/// configured terminator (if any) is checked here, before `emit`, so +/// callers don't need to know about format-specific framing rules. +async fn read_stream_events( + response: reqwest::Response, + streaming: &StreamingConfig, + mut emit: F, +) -> Result<(), CliError> +where + F: FnMut(String) -> Result<(), CliError>, +{ + let mut line_stream = ResponseLineStream::new(response); + match streaming { + StreamingConfig::Sse { terminator } => { + let mut decoder = SseLineDecoder::default(); + while let Some(line) = line_stream.next_line().await? { + if let Some(payload) = decoder.push_line(&line) { + if let Some(sentinel) = terminator.as_deref() { + if payload == sentinel { + return Ok(()); + } + } + emit(payload)?; + } + } + // EOF: flush any final unterminated event — matches the + // TS runtime's post-loop dispatch (see Stream.template.ts). + if let Some(payload) = decoder.flush() { + if let Some(sentinel) = terminator.as_deref() { + if payload == sentinel { + return Ok(()); + } + } + emit(payload)?; + } + Ok(()) + } + StreamingConfig::Json { .. } | StreamingConfig::Text => { + while let Some(line) = line_stream.next_line().await? { + match decode_stream_event(streaming, &line) { + StreamEvent::Skip => continue, + StreamEvent::Terminate => return Ok(()), + StreamEvent::Event(payload) => emit(payload)?, + } + } + Ok(()) + } + } +} + +/// Adapt a `reqwest::Response`'s byte stream into a line iterator. Keeps +/// a small in-memory buffer of bytes received but not yet terminated +/// by a newline; reads stop at LF and emit the preceding bytes (CR is +/// also stripped) as a UTF-8 string. The terminating line of a +/// response that doesn't end with a newline is still emitted from +/// `next_line` before the stream returns `None`. +struct ResponseLineStream { + stream: futures_util::stream::BoxStream<'static, reqwest::Result>, + buf: Vec, + done: bool, +} + +impl ResponseLineStream { + fn new(response: reqwest::Response) -> Self { + Self { + stream: Box::pin(response.bytes_stream()), + buf: Vec::with_capacity(4096), + done: false, + } + } + + async fn next_line(&mut self) -> Result, CliError> { + loop { + // Emit a buffered line if a newline has already been received. + if let Some(idx) = self.buf.iter().position(|&b| b == b'\n') { + let mut line: Vec = self.buf.drain(..=idx).collect(); + line.pop(); // drop the trailing '\n' + if line.last() == Some(&b'\r') { + line.pop(); + } + return Ok(Some(decode_line_lossy(line))); + } + + // If the stream is exhausted, flush any trailing bytes that + // didn't end with a newline (servers commonly omit the final + // newline on the last event of an NDJSON stream). + if self.done { + if self.buf.is_empty() { + return Ok(None); + } + let mut line: Vec = std::mem::take(&mut self.buf); + if line.last() == Some(&b'\r') { + line.pop(); + } + return Ok(Some(decode_line_lossy(line))); + } + + // Pull the next chunk off the wire. + match self.stream.next().await { + Some(Ok(chunk)) => self.buf.extend_from_slice(&chunk), + Some(Err(err)) => { + return Err(anyhow::Error::from(err) + .context("Failed to read streaming response chunk") + .into()); + } + None => self.done = true, + } + } + } +} + +/// Decode a single line as UTF-8, replacing invalid sequences with +/// U+FFFD so a malformed byte (e.g. truncated multibyte from a flaky +/// proxy) doesn't crash the stream. +fn decode_line_lossy(bytes: Vec) -> String { + match String::from_utf8(bytes) { + Ok(s) => s, + Err(e) => String::from_utf8_lossy(&e.into_bytes()).into_owned(), + } +} + +/// Executes an API method call. +/// +/// This is the core function of the CLI that handles: +/// 1. Parameter validation and URL construction. +/// 2. Request body validation against the Discovery Document schema. +/// 3. Authentication (OAuth or none). +/// 4. Sending the HTTP request (GET/POST/etc). +/// 5. Handling various response types (JSON, binary). +/// 6. Auto-pagination for list endpoints. +#[allow(clippy::too_many_arguments)] +pub async fn execute_method( + doc: &RestDescription, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + auth_provider: &DynAuthProvider, + output_path: Option<&str>, + upload: Option>, + binary_body_path: Option<&str>, + dry_run: bool, + pagination: &PaginationConfig, + pipeline: &crate::formatter::OutputPipeline, + capture_output: bool, + base_url_override: Option<&str>, + http_config: &crate::http::HttpConfig, + no_extract: bool, + no_retry: bool, + no_stream: bool, + extra_headers: &[(String, String)], +) -> Result, CliError> { + let binary_flag = method + .binary_request_body + .as_ref() + .map(|b| b.flag_name.as_str()); + if binary_body_path.is_some() && binary_flag.is_none() { + return Err(CliError::Validation( + "binary body path is only valid for operations with a binary request body" + .to_string(), + )); + } + if binary_body_path.is_some() && body_json.is_some() { + return Err(CliError::Validation(format!( + "--{} and --json are mutually exclusive", + binary_flag.unwrap_or("file"), + ))); + } + + let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload.is_some(), base_url_override, extra_headers)?; + + // Human-readable identifier for the operation, used in + // `x-fern-sdk-return-value` extraction errors so the user can find + // the offending op when the response shape disagrees with the + // spec. Prefer the `operationId` (matches the spec text) and fall + // back to `GET /things` when it's absent. + let method_descriptor = match method.id.as_deref() { + Some(id) => format!("'{id}'"), + None => format!( + "{} {}", + method.http_method.to_ascii_uppercase(), + method.path + ), + }; + + if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; + let mut dry_run_info = json!({ + "dry_run": true, + "url": input.full_url, + "method": method.http_method, + "query_params": input.query_params, + "headers": input.header_params, + "body": input.body, + "is_multipart_upload": input.is_upload, + }); + if !content_type_header.is_empty() { + dry_run_info["content_type"] = json!(content_type_header); + } + if method.body_encoding.is_form() { + if let Some(ref body_val) = input.body { + dry_run_info["form_encoded_body"] = json!(encode_form_body(body_val)); + } + } + if let Some(raw) = binary_body_path { + let (content_type, flag_name) = method + .binary_request_body + .as_ref() + .map(|b| (b.content_type.as_str(), b.flag_name.as_str())) + .unwrap_or(("", "")); + let (source, transfer) = match BinaryBodySource::parse(raw) { + BinaryBodySource::File(p) => (json!({ "file": p }), "content-length"), + BinaryBodySource::Stdin => (json!({ "stdin": true }), "chunked"), + }; + dry_run_info["binary_body"] = json!({ + "source": source, + "content_type": content_type, + "transfer_encoding": transfer, + "flag": flag_name, + }); + } + if capture_output { + return Ok(Some(dry_run_info)); + } + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &dry_run_info, false, true) + .context("Failed to write output")?; + return Ok(None); + } + + let endpoint_pag = method.pagination.as_ref(); + let mut page_state: PageState = PageState::initial(endpoint_pag); + let mut pages_fetched: u32 = 0; + let mut captured_values = Vec::new(); + let auth_metadata = endpoint_metadata_for(method); + + // Build the client once outside the pagination loop. Client construction + // reads env vars and (with TLS) builds a connection pool; rebuilding per + // page would defeat connection reuse and emit any one-time warnings + // (e.g. insecure-mode) once per page. + let client = http_config.build_client()?; + + loop { + // Snapshot the URL we are about to hit so the response handler can + // resolve relative `next_path` values against it. Captured before + // `page_state` is borrowed mutably below. + let current_url = page_state + .url_override() + .unwrap_or(&input.full_url) + .to_string(); + + let method_id = method.id.as_deref().unwrap_or("unknown"); + let start = std::time::Instant::now(); + + // Retry loop. Each iteration rebuilds the request (so streaming + // bodies start fresh) and dispatches it. `retry_attempt` is + // 0-indexed and counts *prior* sends — we increment it after + // each retry, then re-check the policy before the next send. + // + // Stdin-sourced binary bodies are *not* replayable: the first + // attempt consumes the pipe and any retry would silently send + // an empty body. Disable retries for that case so we preserve + // the pre-retry behavior (a single attempt, surface whatever + // the server returns) rather than masking the original failure. + let retries_cfg = if binary_body_is_stdin(binary_body_path) { + None + } else { + method.retries.as_ref() + }; + let mut retry_attempt: u32 = 0; + let response = loop { + let request = build_http_request( + &client, + method, + &input, + auth_provider, + &auth_metadata, + &page_state, + pages_fetched, + &upload, + binary_body_path, + pagination, + ) + .await?; + + match request.send().await { + Ok(resp) => { + let status = resp.status(); + let retry_after_header = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + if let Some(cfg) = retries_cfg { + let outcome = RetryOutcome { + status: Some(status.as_u16()), + retry_after: retry_after_header.as_deref(), + }; + if let Some(delay) = decide_retry( + retry_attempt, + &outcome, + cfg, + &method.http_method, + method.idempotent, + no_retry, + ) { + tracing::warn!( + api_method = method_id, + http_method = %method.http_method, + status = status.as_u16(), + attempt = retry_attempt + 1, + delay_ms = delay.as_millis() as u64, + "retrying after retryable HTTP status", + ); + // Drain the body so the connection can be + // returned to the pool. We don't surface + // the body on retried responses; the final + // response (success or terminal failure) + // is what the user sees. + let _ = resp.bytes().await; + tokio::time::sleep(delay).await; + retry_attempt += 1; + continue; + } + } + break resp; + } + Err(e) => { + if let Some(cfg) = retries_cfg { + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + if let Some(delay) = decide_retry( + retry_attempt, + &outcome, + cfg, + &method.http_method, + method.idempotent, + no_retry, + ) { + tracing::warn!( + api_method = method_id, + http_method = %method.http_method, + attempt = retry_attempt + 1, + delay_ms = delay.as_millis() as u64, + error = %e, + "retrying after network/transport failure", + ); + tokio::time::sleep(delay).await; + retry_attempt += 1; + continue; + } + } + // Surface a human-readable hint to stderr if this looks like + // a TLS failure — the most common debugging hump for users + // behind corporate proxies / interception tools. The hint is + // a side effect; the error then propagates up like any other. + crate::http::maybe_emit_tls_hint(http_config, &e); + return Err(anyhow::Error::from(e).context("HTTP request failed").into()); + } + } + }; + let latency_ms = start.elapsed().as_millis() as u64; + + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + if !status.is_success() { + let error_body = response.text().await.unwrap_or_default(); + tracing::warn!( + api_method = method_id, + http_method = %method.http_method, + status = status.as_u16(), + latency_ms = latency_ms, + "API error" + ); + return handle_error_response( + status, + &error_body, + auth_provider.as_ref(), + &auth_metadata, + ); + } + + tracing::debug!( + api_method = method_id, + http_method = %method.http_method, + status = status.as_u16(), + latency_ms = latency_ms, + content_type = %content_type, + is_upload = input.is_upload, + page = pages_fetched, + "API request" + ); + + // Streaming response branch. Selected when: + // - the operation declares `x-fern-streaming`, AND + // - the caller hasn't explicitly opted out via `--no-stream`, + // AND + // - we aren't capturing into a single `Value` for a + // programmatic caller (those need a unary shape and treat + // `--no-stream` as implicit). + // + // `--no-stream` and `capture_output` both fall through to the + // existing buffered path below: the body is read once and + // either pretty-printed (no_stream from the CLI) or decoded + // into a `Value` (capture_output from `AppContext::invoke`). + if let Some(streaming) = method.streaming.as_ref() { + if !no_stream && !capture_output { + // Note: `pages_fetched` is intentionally left untouched + // here. Streaming endpoints are single-request by + // construction (see the parse-time mutual exclusion + // with `x-fern-pagination`), so the pagination loop + // never re-enters; bumping the counter would only + // confuse the unrelated request-tracing in `debug!`. + stream_response( + response, + streaming, + method.return_value.as_deref(), + no_extract, + pipeline, + &method_descriptor, + ) + .await?; + break; + } + // Buffered fallback: collect every event into a single + // JSON array (or unwrap the lone event when only one + // arrived) so the downstream printer / capture path sees + // the kind of value it expects from a unary endpoint. The + // server may legitimately send a non-streaming body, so we + // still parse it line-by-line and fall back to a + // single-value array when the body holds one JSON object. + let buffered = buffer_streaming_response( + response, + streaming, + method.return_value.as_deref(), + no_extract, + &method_descriptor, + ) + .await?; + if capture_output { + captured_values.push(buffered); + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &buffered, false, true) + .context("Failed to write output")?; + } + break; + } + + let is_json = + content_type.contains("application/json") || content_type.contains("text/json"); + + if is_json || content_type.is_empty() { + let body_text = response + .text() + .await + .context("Failed to read response body")?; + + let response_body = body_text; + let should_continue = handle_json_response( + &response_body, + pagination, + endpoint_pag, + pipeline, + &mut pages_fetched, + &mut page_state, + capture_output, + &mut captured_values, + ¤t_url, + &input.query_params, + method.return_value.as_deref(), + no_extract, + &method_descriptor, + ) + .await?; + + if should_continue { + continue; + } + } else if let Some(res) = handle_binary_response( + response, + &content_type, + output_path, + pipeline, + capture_output, + ) + .await? + { + captured_values.push(res); + } + + break; + } + + if capture_output && !captured_values.is_empty() { + if captured_values.len() == 1 { + return Ok(Some(captured_values.pop().unwrap())); + } else { + return Ok(Some(Value::Array(captured_values))); + } + } + + Ok(None) +} + +/// Serialize a query parameter value according to its OpenAPI style. +fn serialize_query_param( + key: &str, + value: &Value, + param_def: Option<&crate::openapi::discovery::MethodParameter>, +) -> Vec<(String, String)> { + let style = param_def + .and_then(|p| p.style.as_deref()) + .unwrap_or("form"); + let explode = param_def + .and_then(|p| p.explode) + .unwrap_or(style == "form"); + + match style { + "deepObject" => serialize_deep_object(key, value), + _ => serialize_form(key, value, explode), + } +} + +fn serialize_deep_object(key: &str, value: &Value) -> Vec<(String, String)> { + match value { + Value::Object(_) => { + // Wrap as {key: value} so serde-qs produces key[...]=... pairs. + // ArrayFormat::Unindexed gives filter[tags]=a&filter[tags]=b, + // consistent with the Fern Python and C# SDKs. + let wrapped = serde_json::json!({ key: value }); + let config = serde_qs::Config::new() + .array_format(serde_qs::ArrayFormat::Unindexed); + match config.serialize_string(&wrapped) { + Ok(qs) => { + // serde-qs URL-encodes the output; decode each pair + qs.split('&') + .filter(|s| !s.is_empty()) + .filter_map(|pair| { + let (k, v) = pair.split_once('=')?; + let decoded_k = percent_encoding::percent_decode_str(k) + .decode_utf8_lossy() + .into_owned(); + let decoded_v = percent_encoding::percent_decode_str(v) + .decode_utf8_lossy() + .into_owned(); + Some((decoded_k, decoded_v)) + }) + .collect() + } + Err(_) => vec![(key.to_string(), value_to_query_string(value))], + } + } + _ => vec![(key.to_string(), value_to_query_string(value))], + } +} + +fn serialize_form(key: &str, value: &Value, explode: bool) -> Vec<(String, String)> { + match value { + Value::Array(arr) if explode => arr + .iter() + .map(|v| (key.to_string(), value_to_query_string(v))) + .collect(), + Value::Array(arr) => { + let joined = arr + .iter() + .map(value_to_query_string) + .collect::>() + .join(","); + vec![(key.to_string(), joined)] + } + _ => vec![(key.to_string(), value_to_query_string(value))], + } +} + +fn value_to_query_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + other => other.to_string(), + } +} + +fn effective_root_url(method: &RestMethod, doc: &RestDescription) -> String { + if !method.root_url.is_empty() { method.root_url.clone() } else { doc.root_url.clone() } +} + +/// Prepend `doc.base_path` (sourced from `x-fern-base-path`) to `base`, +/// inserting exactly one slash between the two segments regardless of +/// whether either side already has a slash on its boundary. Returns +/// `base` unchanged when `doc.base_path` is `None` or normalizes to +/// empty. +/// +/// Examples (server URL × base_path slash matrix): +/// - `"https://x/"` + `"/v1"` → `"https://x/v1"` +/// - `"https://x"` + `"/v1"` → `"https://x/v1"` +/// - `"https://x/"` + `"v1"` → `"https://x/v1"` +/// - `"https://x"` + `"v1"` → `"https://x/v1"` +/// - `"https://x"` + `"/v1/"` → `"https://x/v1"` (trailing slash on +/// base_path is stripped; `build_url` re-adds one before the path) +/// +/// `build_url` calls this helper uniformly across all three URL sources +/// — `--base-url` override, `doc.base_url`, and `effective_root_url + +/// service_path` — so the base path is applied *additively* on top of +/// any one of them. In particular, `--base-url https://staging/v2` on a +/// spec with `x-fern-base-path: /v1` produces `https://staging/v2/v1/...`, +/// not `https://staging/v2/...`: `x-fern-base-path` is part of the spec's +/// logical URL structure, not a property of any specific host. +/// +/// Mirrors fern-api/fern's openapi-ir-parser: +/// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernBasePath.ts`. +/// +/// The base path passed in is expected to already have any `{param}` +/// placeholders substituted — `build_url` calls `render_path_template` +/// on `doc.base_path` first so this helper only deals with the +/// post-substitution slash-edge logic. +fn apply_base_path(base: &str, base_path: Option<&str>) -> String { + let Some(bp) = base_path else { + return base.to_string(); + }; + let bp_trimmed = bp.trim_matches('/'); + if bp_trimmed.is_empty() { + return base.to_string(); + } + let base_trimmed = base.trim_end_matches('/'); + format!("{base_trimmed}/{bp_trimmed}") +} + +fn build_url( + doc: &RestDescription, + method: &RestMethod, + params: &Map, + is_upload: bool, + base_url_override: Option<&str>, +) -> Result<(String, Vec<(String, String)>), CliError> { + // Build URL base and path. The base_url here is just the server (or + // override) plus any Discovery `service_path`; x-fern-base-path is + // applied as a separate step below so the slash-edge logic stays in + // one place and applies to all three base sources (override, explicit + // `base_url`, and effective root_url + service_path). + let raw_base_url = if let Some(b) = base_url_override { + b.trim_end_matches('/').to_string() + } else if let Some(b) = &doc.base_url { + b.clone() + } else { + format!("{}{}", effective_root_url(method, doc), doc.service_path) + }; + // Render any `{param}` placeholders in `x-fern-base-path` (e.g. + // `/{tenant}/v1`) against the operation's parameters. The placeholder + // names are also collected so we can exclude them from the query + // string below — the param has been consumed by the URL path and + // must not leak as `?tenant=acme`. Mirrors upstream Fern where base + // path placeholders are baked into endpoint paths at Definition build + // time and then resolved by the SDK's path-parameter renderer at + // request time. + let rendered_base_path = doc + .base_path + .as_deref() + .map(|bp| render_path_template(bp, params)) + .transpose()?; + let base_path_parameters: HashSet<&str> = doc + .base_path + .as_deref() + .map(extract_template_path_parameters) + .unwrap_or_default(); + let base_url = apply_base_path(&raw_base_url, rendered_base_path.as_deref()); + + // Prefer flatPath when its placeholders match the method's path parameters. + // Some Discovery Documents (e.g., Slides presentations.get) have flatPath + // placeholders that don't match parameter names ({presentationsId} vs + // {presentationId}). In those cases, fall back to path which uses RFC 6570 + // operators ({+var}) that this function already handles. + let path_template = match method.flat_path.as_deref() { + Some(fp) => { + let all_match = method + .parameters + .iter() + .filter(|(_, p)| p.location.as_deref() == Some("path")) + .all(|(name, _)| { + let plain = format!("{{{name}}}"); + let plus = format!("{{+{name}}}"); + fp.contains(&plain) || fp.contains(&plus) + }); + if all_match { + fp + } else { + method.path.as_str() + } + } + None => method.path.as_str(), + }; + + // Substitute path parameters and separate query parameters + let path_parameters = extract_template_path_parameters(path_template); + let mut query_params: Vec<(String, String)> = Vec::new(); + + for (key, value) in params { + if path_parameters.contains(key.as_str()) { + continue; + } + // Params that backfill placeholders in `x-fern-base-path` have + // already been consumed by the URL path; they must not also + // appear as query string entries. + if base_path_parameters.contains(key.as_str()) { + continue; + } + + let is_path_param = method + .parameters + .get(key) + .and_then(|p| p.location.as_deref()) + == Some("path"); + + if is_path_param { + return Err(CliError::Validation(format!( + "Path parameter '{key}' was provided but is not present in URL template '{path_template}'" + ))); + } + + // Use style-aware serialization for query parameters. + // For backward compatibility, `repeated` params still use the legacy + // expansion (equivalent to form+explode). + let param_def = method.parameters.get(key); + let is_repeated = param_def.map(|p| p.repeated).unwrap_or(false); + + if is_repeated { + if let Value::Array(arr) = value { + for item in arr { + let val_str = match item { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + query_params.push((key.clone(), val_str)); + } + continue; + } + } + + let pairs = serialize_query_param(key, value, param_def); + query_params.extend(pairs); + } + + let url_path = render_path_template(path_template, params)?; + + let full_url = if is_upload { + // Use the upload endpoint from the Discovery Document + let upload_endpoint = method + .media_upload + .as_ref() + .and_then(|mu| mu.protocols.as_ref()) + .and_then(|p| p.simple.as_ref()) + .map(|s| s.path.as_str()) + .ok_or_else(|| { + CliError::Validation( + "Method supports media upload but no upload path found in Discovery Document" + .to_string(), + ) + })?; + let upload_path = render_path_template(upload_endpoint, params)?; + // Compose the upload host with the spec-level base_path the same + // way the non-upload branch does, so x-fern-base-path is applied + // uniformly. This branch is currently unreachable from OpenAPI + // specs (only Google Discovery sets `media_upload`, and Discovery + // specs don't carry `base_path`), but keeping the wiring + // symmetric prevents a silent gap if either side ever changes. + let root = base_url_override + .map(|b| b.trim_end_matches('/').to_string()) + .unwrap_or_else(|| effective_root_url(method, doc).trim_end_matches('/').to_string()); + let root = apply_base_path(&root, rendered_base_path.as_deref()); + format!("{root}{upload_path}") + } else { + match (base_url.ends_with('/'), url_path.starts_with('/')) { + (true, true) => format!("{}{}", base_url.trim_end_matches('/'), url_path), + (false, false) => format!("{base_url}/{url_path}"), + _ => format!("{base_url}{url_path}"), + } + }; + + Ok((full_url, query_params)) +} + +fn extract_template_path_parameters(path_template: &str) -> HashSet<&str> { + let mut found = HashSet::new(); + let mut cursor = 0; + + while let Some(open_idx) = path_template[cursor..].find('{') { + let token_start = cursor + open_idx; + let Some(close_idx) = path_template[token_start..].find('}') else { + break; + }; + + let token_end = token_start + close_idx; + let token = &path_template[token_start + 1..token_end]; + if let Some(key) = token.strip_prefix('+') { + found.insert(key); + } else { + found.insert(token); + } + cursor = token_end + 1; + } + + found +} + +fn render_path_template( + path_template: &str, + params: &Map, +) -> Result { + let mut rendered = String::with_capacity(path_template.len()); + let mut cursor = 0; + + while let Some(open_idx) = path_template[cursor..].find('{') { + let token_start = cursor + open_idx; + rendered.push_str(&path_template[cursor..token_start]); + + let Some(close_idx) = path_template[token_start..].find('}') else { + rendered.push_str(&path_template[token_start..]); + return Ok(rendered); + }; + + let token_end = token_start + close_idx; + let token = &path_template[token_start + 1..token_end]; + let (is_plus, key) = if let Some(key) = token.strip_prefix('+') { + (true, key) + } else { + (false, token) + }; + + if let Some(value) = params.get(key) { + let val_str = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + let encoded = if is_plus { + let validated = crate::validate::validate_resource_name(&val_str)?; + crate::validate::encode_path_preserving_slashes(validated) + } else { + crate::validate::encode_path_segment(&val_str) + }; + rendered.push_str(&encoded); + } else { + rendered.push_str(&path_template[token_start..=token_end]); + } + + cursor = token_end + 1; + } + + rendered.push_str(&path_template[cursor..]); + Ok(rendered) +} + +/// Resolves the MIME type for the uploaded media content. +/// +/// Priority: +/// 1. `--upload-content-type` flag (explicit override) +/// 2. File extension inference (common extensions mapped to MIME types) +/// 3. Metadata `mimeType` (fallback for backward compatibility) +/// 4. `application/octet-stream` +/// +/// All returned MIME types have control characters stripped to prevent +/// MIME header injection via user-controlled metadata. +fn resolve_upload_mime( + explicit: Option<&str>, + upload_path: Option<&str>, + metadata: &Option, +) -> String { + let raw = explicit + .map(|s| s.to_string()) + .or_else(|| upload_path.and_then(mime_from_extension)) + .or_else(|| { + metadata + .as_ref() + .and_then(|m| m.get("mimeType")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + // Strip CR/LF and other control characters to prevent MIME header injection. + let sanitized: String = raw.chars().filter(|c| !c.is_control()).collect(); + if sanitized.is_empty() { + "application/octet-stream".to_string() + } else { + sanitized + } +} + +/// Simple MIME type inference from file extension. +/// Returns `None` for unrecognized extensions. +fn mime_from_extension(path: &str) -> Option { + let ext = path.rsplit('.').next()?.to_lowercase(); + let mime = match ext.as_str() { + "txt" => "text/plain", + "html" | "htm" => "text/html", + "css" => "text/css", + "csv" => "text/csv", + "xml" => "application/xml", + "json" => "application/json", + "js" => "application/javascript", + "pdf" => "application/pdf", + "zip" => "application/zip", + "gz" | "gzip" => "application/gzip", + "tar" => "application/x-tar", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "webp" => "image/webp", + "ico" => "image/x-icon", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "mp4" => "video/mp4", + "webm" => "video/webm", + "md" | "markdown" => "text/markdown", + "yaml" | "yml" => "application/yaml", + "toml" => "application/toml", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "wasm" => "application/wasm", + _ => return None, + }; + Some(mime.to_string()) +} + +/// Streams stdin as a raw request body via chunked transfer encoding. +/// Used when the user passes `-` to the binary-body flag. +fn build_stdin_body_stream() -> reqwest::Body { + let stream = tokio_util::io::ReaderStream::new(tokio::io::stdin()); + reqwest::Body::wrap_stream(stream) +} + +/// Streams a file as a raw request body. Used for operations whose request +/// body is declared as a binary content type (e.g. `application/octet-stream`). +/// Memory usage stays at O(64 KB) regardless of file size. +/// +/// `flag_name` is the spec-derived CLI flag (`file`, `body`, or whatever +/// `x-fern-parameter-name` set) — surfaced in the error message if the file +/// disappears between the upfront `metadata()` check and stream open (TOCTOU). +fn build_binary_file_stream( + file_path: &str, + file_size: u64, + flag_name: &str, +) -> (reqwest::Body, u64) { + let file_path_owned = file_path.to_owned(); + let flag_owned = flag_name.to_owned(); + let stream = futures_util::stream::once(async move { + tokio::fs::File::open(&file_path_owned).await.map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to open --{flag_owned} '{file_path_owned}': {e}"), + ) + }) + }) + .map_ok(tokio_util::io::ReaderStream::new) + .try_flatten(); + + (reqwest::Body::wrap_stream(stream), file_size) +} + +/// Builds a streaming multipart/related body for media upload requests. +/// +/// Instead of reading the entire file into memory, this streams the file in +/// chunks via `ReaderStream`, keeping memory usage at O(64 KB) regardless of +/// file size. The `Content-Length` is pre-computed from file metadata so APIs +/// Generate a unique boundary ID for multipart requests using timestamp. +fn generate_boundary_id() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64 +} + +/// still receive the correct header without buffering. +/// +/// Returns `(body, content_type, content_length)`. +fn build_multipart_stream( + metadata: &Option, + file_path: &str, + file_size: u64, + media_mime: &str, +) -> Result<(reqwest::Body, String, u64), CliError> { + let boundary = format!("fern_boundary_{:016x}", generate_boundary_id()); + + let media_mime = media_mime.to_string(); + + let metadata_json = match metadata { + Some(m) => serde_json::to_string(m).map_err(|e| { + CliError::Validation(format!("Failed to serialize upload metadata: {e}")) + })?, + None => "{}".to_string(), + }; + + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\ + --{boundary}\r\nContent-Type: {media_mime}\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + + let content_length = preamble.len() as u64 + file_size + postamble.len() as u64; + let content_type = format!("multipart/related; boundary={boundary}"); + + let preamble_bytes: bytes::Bytes = preamble.into_bytes().into(); + let postamble_bytes: bytes::Bytes = postamble.into_bytes().into(); + + let file_path_owned = file_path.to_owned(); + let file_stream = futures_util::stream::once(async move { + tokio::fs::File::open(&file_path_owned).await.map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to open upload file '{file_path_owned}': {e}"), + ) + }) + }) + .map_ok(tokio_util::io::ReaderStream::new) + .try_flatten(); + + let stream = futures_util::stream::once(async { Ok::<_, std::io::Error>(preamble_bytes) }) + .chain(file_stream) + .chain(futures_util::stream::once(async { + Ok::<_, std::io::Error>(postamble_bytes) + })); + + Ok(( + reqwest::Body::wrap_stream(stream), + content_type, + content_length, + )) +} + +/// Builds a multipart/related body from in-memory bytes. +/// +/// Used when the upload content is constructed in memory (e.g., a Gmail RFC 5322 +/// message with attachments) rather than read from a file on disk. +fn build_multipart_bytes( + metadata: &Option, + data: &[u8], + media_mime: &str, +) -> Result<(reqwest::Body, String, u64), CliError> { + let boundary = format!("fern_boundary_{:016x}", generate_boundary_id()); + + let metadata_json = match metadata { + Some(m) => serde_json::to_string(m).map_err(|e| { + CliError::Validation(format!("Failed to serialize upload metadata: {e}")) + })?, + None => "{}".to_string(), + }; + + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\ + --{boundary}\r\nContent-Type: {media_mime}\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + + let mut body = Vec::with_capacity(preamble.len() + data.len() + postamble.len()); + body.extend_from_slice(preamble.as_bytes()); + body.extend_from_slice(data); + body.extend_from_slice(postamble.as_bytes()); + + let content_length = body.len() as u64; + let content_type = format!("multipart/related; boundary={boundary}"); + + Ok((reqwest::Body::from(body), content_type, content_length)) +} + +/// Builds a buffered multipart/related body for media upload requests. +/// +/// This is the legacy implementation retained for unit tests that need +/// a fully materialized body to assert against. +/// +/// Returns the body bytes and the Content-Type header value (with boundary). +#[cfg(test)] +fn build_multipart_body( + metadata: &Option, + file_bytes: &[u8], + media_mime: &str, +) -> Result<(Vec, String), CliError> { + let boundary = format!("fern_boundary_{:016x}", generate_boundary_id()); + + // Build multipart/related body + let metadata_json = metadata + .as_ref() + .map(|m| serde_json::to_string(m).unwrap_or_else(|_| "{}".to_string())) + .unwrap_or_else(|| "{}".to_string()); + + let mut body = Vec::new(); + // Part 1: JSON metadata + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice(b"Content-Type: application/json; charset=UTF-8\r\n\r\n"); + body.extend_from_slice(metadata_json.as_bytes()); + body.extend_from_slice(b"\r\n"); + // Part 2: File content + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice(format!("Content-Type: {media_mime}\r\n\r\n").as_bytes()); + body.extend_from_slice(file_bytes); + body.extend_from_slice(b"\r\n"); + // Closing boundary + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + + let content_type = format!("multipart/related; boundary={boundary}"); + Ok((body, content_type)) +} + +/// Intentional duplication from `graphql/executor.rs` — no shared module by design. +fn set_nested_value(obj: &mut Map, path: &str, value: Value) { + match path.split_once('.') { + None => { + obj.insert(path.to_string(), value); + } + Some((head, tail)) => { + let nested = obj + .entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(nested_map) = nested { + set_nested_value(nested_map, tail, value); + } + } + } +} + +/// Apply the appropriate body encoding to the request based on the +/// [`BodyEncoding`] variant. Sets the `Content-Type` header and body payload. +fn encode_request_body( + request: reqwest::RequestBuilder, + body: &Value, + encoding: &BodyEncoding, +) -> reqwest::RequestBuilder { + match encoding { + BodyEncoding::Json => request + .header("Content-Type", encoding.content_type()) + .json(body), + BodyEncoding::FormUrlEncoded => { + let encoded = encode_form_body(body); + request + .header("Content-Type", encoding.content_type()) + .body(encoded) + } + } +} + +/// Encode a JSON `Value` (expected to be an Object) into a +/// `application/x-www-form-urlencoded` string. Top-level keys are +/// emitted as-is; arrays repeat the key (e.g. `tag=a&tag=b`). +/// Nested objects and arrays-of-objects are JSON-encoded as the value +/// — no dot-notation or bracket expansion — so the encoding stays +/// predictable for servers that treat `.` as a literal character. +/// Non-object top-level values are serialized as a single +/// `body=` pair. +fn encode_form_body(val: &Value) -> String { + let mut pairs: Vec<(String, String)> = Vec::new(); + if let Value::Object(map) = val { + collect_form_pairs(map, &mut pairs); + } else { + pairs.push(("body".to_string(), value_to_form_str(val))); + } + form_urlencoded::Serializer::new(String::new()) + .extend_pairs(pairs) + .finish() +} + +fn collect_form_pairs(map: &Map, out: &mut Vec<(String, String)>) { + for (key, value) in map { + match value { + Value::Array(items) => { + for item in items { + out.push((key.clone(), value_to_form_str(item))); + } + } + _ => out.push((key.clone(), value_to_form_str(value))), + } + } +} + +fn value_to_form_str(val: &Value) -> String { + match val { + Value::String(s) => s.clone(), + Value::Null => String::new(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + other => other.to_string(), + } +} + +/// +/// CLI flags arrive as `Value::String` (clap stores them as `String`), but a +/// body field declared `integer` / `number` / `boolean` should land in the +/// JSON body with the right runtime type, not as a quoted string. Values +/// supplied via `--params` are already typed by `serde_json` and pass through +/// unchanged. `object` and `array` types are JSON-decoded so callers can pass +/// nested structures via individual flags (e.g. `--addresses '[{"city":"SF"}]'`). +fn coerce_body_param_value(value: &Value, param_type: Option<&str>) -> Result { + let Value::String(raw) = value else { + return Ok(value.clone()); + }; + match param_type { + Some("integer") => raw + .parse::() + .map(|n| Value::Number(n.into())) + .map_err(|e| CliError::Validation(format!("Invalid integer body value '{raw}': {e}"))), + Some("number") => { + let n = raw.parse::().map_err(|e| { + CliError::Validation(format!("Invalid number body value '{raw}': {e}")) + })?; + serde_json::Number::from_f64(n) + .map(Value::Number) + .ok_or_else(|| CliError::Validation(format!("Non-finite number body value '{raw}'"))) + } + Some("boolean") => match raw.as_str() { + "true" | "1" => Ok(Value::Bool(true)), + "false" | "0" => Ok(Value::Bool(false)), + _ => Err(CliError::Validation(format!( + "Invalid boolean body value '{raw}' (expected true/false)" + ))), + }, + Some("object") | Some("array") => serde_json::from_str(raw).map_err(|e| { + CliError::Validation(format!("Invalid JSON body value for nested field: {e}")) + }), + _ => Ok(Value::String(raw.clone())), + } +} + +/// Validates a JSON body against a Discovery Document schema. +fn validate_body_against_schema( + body: &Value, + schema_name: &str, + doc: &RestDescription, +) -> Result<(), CliError> { + let mut errors = Vec::new(); + validate_value(body, schema_name, doc, "$", &mut errors); + + if !errors.is_empty() { + return Err(CliError::Validation(format!( + "Request body failed schema validation:\n- {}", + errors.join("\n- ") + ))); + } + + Ok(()) +} + +fn validate_value( + value: &Value, + schema_ref_name: &str, + doc: &RestDescription, + path: &str, + errors: &mut Vec, +) { + let schema = match doc.schemas.get(schema_ref_name) { + Some(s) => s, + None => { + errors.push(format!("{path}: Schema '{schema_ref_name}' not found")); + return; + } + }; + + // If the top-level schema is an object + if schema.schema_type.as_deref() == Some("object") || !schema.properties.is_empty() { + if let Value::Object(obj) = value { + validate_properties(obj, &schema.properties, &schema.required, doc, path, errors); + } else { + errors.push(format!("{path}: Expected object")); + } + } +} + +fn validate_properties( + obj: &Map, + properties: &HashMap, + required_keys: &[String], + doc: &RestDescription, + path: &str, + errors: &mut Vec, +) { + // Check required keys first + for req_key in required_keys { + if !obj.contains_key(req_key) { + errors.push(format!("{path}: Missing required property '{req_key}'")); + } + } + + // An empty properties map means "any additional properties are allowed" + // (JSON Schema default when additionalProperties is not explicitly false). + if properties.is_empty() { + return; + } + + let valid_keys: std::collections::HashSet<&String> = properties.keys().collect(); + + for (key, val) in obj { + let current_path = if path == "$" { + key.clone() + } else { + format!("{path}.{key}") + }; + + if !valid_keys.contains(key) { + errors.push(format!( + "{current_path}: Unknown property. Valid properties: {:?}", + valid_keys.iter().map(|k| k.as_str()).collect::>() + )); + continue; + } + + let prop_schema = &properties[key]; + validate_property(val, prop_schema, doc, ¤t_path, errors); + } +} + +fn validate_property( + value: &Value, + prop_schema: &crate::openapi::discovery::JsonSchemaProperty, + doc: &RestDescription, + path: &str, + errors: &mut Vec, +) { + // 1. Resolve $ref if present + if let Some(ref_name) = &prop_schema.schema_ref { + validate_value(value, ref_name, doc, path, errors); + return; + } + + // 2. Type checking + if let Some(expected_type) = &prop_schema.prop_type { + let type_matches = match (expected_type.as_str(), value) { + ("string", Value::String(_)) => true, + ("integer", Value::Number(n)) => n.is_i64() || n.is_u64(), + ("number", Value::Number(_)) => true, + ("boolean", Value::Bool(_)) => true, + ("array", Value::Array(_)) => true, + ("object", Value::Object(_)) => true, + ("any", _) => true, + _ => false, + }; + + if !type_matches { + errors.push(format!( + "{path}: Expected type '{expected_type}', found {}", + get_value_type(value) + )); + return; // Stop further validation for this property if the type is wrong + } + } + + // 3. Array items validation + if prop_schema.prop_type.as_deref() == Some("array") { + if let Some(items_schema) = &prop_schema.items { + if let Value::Array(arr) = value { + for (i, item) in arr.iter().enumerate() { + let item_path = format!("{path}[{i}]"); + validate_property(item, items_schema, doc, &item_path, errors); + } + } + } + } + + // 4. Object properties validation + if prop_schema.prop_type.as_deref() == Some("object") && !prop_schema.properties.is_empty() { + if let Value::Object(obj) = value { + validate_properties(obj, &prop_schema.properties, &[], doc, path, errors); + } + } + + // 5. Enum validation + if let Some(enum_values) = &prop_schema.enum_values { + if let Value::String(s) = value { + if !enum_values.contains(s) { + errors.push(format!( + "{path}: Value '{s}' is not a valid enum member. Valid options: {enum_values:?}" + )); + } + } + } +} + +fn get_value_type(val: &Value) -> &'static str { + match val { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(n) if n.is_f64() => "number (float)", + Value::Number(_) => "integer", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// Maps a MIME type to a file extension. +pub fn mime_to_extension(mime: &str) -> &str { + if mime.contains("pdf") { + "pdf" + } else if mime.contains("png") { + "png" + } else if mime.contains("jpeg") || mime.contains("jpg") { + "jpg" + } else if mime.contains("gif") { + "gif" + } else if mime.contains("csv") { + "csv" + } else if mime.contains("zip") { + "zip" + } else if mime.contains("xml") { + "xml" + } else if mime.contains("html") { + "html" + } else if mime.contains("plain") { + "txt" + } else if mime.contains("octet-stream") { + "bin" + } else if mime.contains("spreadsheet") || mime.contains("xlsx") { + "xlsx" + } else if mime.contains("document") || mime.contains("docx") { + "docx" + } else if mime.contains("presentation") || mime.contains("pptx") { + "pptx" + } else if mime.contains("script") { + "json" + } else { + "bin" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openapi::discovery::{ + JsonSchema, JsonSchemaProperty, MethodParameter, RestDescription, RestMethod, + }; + use serde_json::json; + + // --------------------------------------------------------------- + // Retry helpers (`x-fern-retries`) + // --------------------------------------------------------------- + + fn enabled_cfg() -> RetriesConfig { + RetriesConfig::default() + } + + #[test] + fn test_is_retryable_status_set_matches_docs() { + // Matches fern TS SDK retryStatusCodes: recommended set: + // 408 / 429 / 502 / 503 / 504. + for s in [408u16, 429, 502, 503, 504] { + assert!(is_retryable_status(s), "{s} should retry"); + } + // 500 is deliberately NOT retried \u2014 see is_retryable_status + // docstring. 425 (Too Early), 501 Not Implemented, and other + // 5xx outside the recommended set are terminal. 4xx client + // errors won't change on retry, so they're terminal too. + for s in [200u16, 301, 400, 401, 403, 404, 422, 425, 500, 501, 505] { + assert!(!is_retryable_status(s), "{s} should NOT retry"); + } + } + + #[test] + fn test_method_allows_retry_idempotent_methods() { + // HTTP-spec-idempotent methods retry regardless of the + // `x-fern-idempotent` extension. + for m in ["GET", "HEAD", "OPTIONS", "DELETE", "PUT"] { + assert!(method_allows_retry(m, false), "{m} should retry by default"); + } + } + + #[test] + fn test_method_allows_retry_non_idempotent_methods_only_when_marked() { + // POST/PATCH only retry when the spec marks the op idempotent. + for m in ["POST", "PATCH"] { + assert!(!method_allows_retry(m, false), "{m} should NOT retry by default"); + assert!(method_allows_retry(m, true), "{m} retries when x-fern-idempotent"); + } + } + + #[test] + fn test_binary_body_is_stdin() { + // Stdin sentinels — retries must be disabled. + assert!(binary_body_is_stdin(Some("-"))); + assert!(binary_body_is_stdin(Some("@-"))); + // File paths — retries are safe (re-opens the file). + assert!(!binary_body_is_stdin(Some("/tmp/audio.mp3"))); + assert!(!binary_body_is_stdin(Some("@/tmp/audio.mp3"))); + // No binary body at all — retries decided by other policy. + assert!(!binary_body_is_stdin(None)); + } + + #[test] + fn test_parse_retry_after_numeric_seconds() { + let now = std::time::SystemTime::now(); + let d = parse_retry_after("5", now).expect("numeric form"); + assert_eq!(d, std::time::Duration::from_secs(5)); + } + + #[test] + fn test_parse_retry_after_zero() { + // `Retry-After: 0` means "retry now". + let d = parse_retry_after("0", std::time::SystemTime::now()).unwrap(); + assert_eq!(d, std::time::Duration::ZERO); + } + + #[test] + fn test_parse_retry_after_whitespace_and_empty() { + assert!(parse_retry_after("", std::time::SystemTime::now()).is_none()); + // Common server spelling has surrounding whitespace; we trim. + let d = parse_retry_after(" 10 ", std::time::SystemTime::now()).unwrap(); + assert_eq!(d, std::time::Duration::from_secs(10)); + } + + #[test] + fn test_parse_retry_after_http_date_future() { + // 60 seconds in the future expressed as IMF-fixdate. + let now = std::time::SystemTime::now(); + let target = now + std::time::Duration::from_secs(60); + let fmt = httpdate::fmt_http_date(target); + let d = parse_retry_after(&fmt, now).expect("http-date form parses"); + // Allow slight skew because `fmt_http_date` rounds to seconds. + assert!(d.as_secs() >= 59 && d.as_secs() <= 60, "got {d:?}"); + } + + #[test] + fn test_parse_retry_after_http_date_in_the_past_clamps_to_zero() { + // A server that emits a past timestamp \u2014 either clock-skew or + // an unusual "you can retry now" gesture \u2014 should collapse to + // an immediate retry rather than underflow. + let now = std::time::SystemTime::now(); + let target = now - std::time::Duration::from_secs(60); + let fmt = httpdate::fmt_http_date(target); + let d = parse_retry_after(&fmt, now).expect("past http-date parses"); + assert_eq!(d, std::time::Duration::ZERO); + } + + #[test] + fn test_parse_retry_after_garbage_returns_none() { + assert!( + parse_retry_after("nonsense", std::time::SystemTime::now()).is_none(), + "bad header surfaces None so the backoff fallback applies" + ); + } + + #[test] + fn test_compute_backoff_delay_no_jitter_is_deterministic() { + let cfg = RetriesConfig { + enabled: true, + max_attempts: 5, + base_delay_ms: 100, + factor: 2.0, + jitter: 0.0, + }; + // attempt=0 \u2192 100ms; attempt=1 \u2192 200; attempt=2 \u2192 400; ... + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.5), + std::time::Duration::from_millis(100) + ); + assert_eq!( + compute_backoff_delay_with_rand(1, &cfg, 0.5), + std::time::Duration::from_millis(200) + ); + assert_eq!( + compute_backoff_delay_with_rand(2, &cfg, 0.5), + std::time::Duration::from_millis(400) + ); + assert_eq!( + compute_backoff_delay_with_rand(3, &cfg, 0.5), + std::time::Duration::from_millis(800) + ); + } + + #[test] + fn test_compute_backoff_delay_jitter_symmetric_around_raw() { + let cfg = RetriesConfig { + enabled: true, + max_attempts: 5, + base_delay_ms: 100, + factor: 2.0, + jitter: 0.5, + }; + // rand=0.5 \u2192 offset is zero \u2192 raw delay. + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.5), + std::time::Duration::from_millis(100) + ); + // rand=0.0 \u2192 subtract half the jitter span. + // span = 100 * 0.5 = 50; offset = (0 - 0.5) * 50 = -25 \u2192 75ms + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.0), + std::time::Duration::from_millis(75) + ); + // rand=1.0 \u2192 add half the jitter span. offset = +25 \u2192 125ms + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 1.0), + std::time::Duration::from_millis(125) + ); + } + + #[test] + fn test_compute_backoff_delay_disabled_returns_zero() { + let cfg = RetriesConfig::disabled(); + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.5), + std::time::Duration::ZERO + ); + } + + #[test] + fn test_compute_backoff_delay_default_entropy_produces_jitter() { + // Regression: an earlier implementation sampled entropy from + // `Instant::now().elapsed()`, which always returns ~0 nanos and + // pinned the jitter sample to a constant — defeating jitter. + // Sample the live `compute_backoff_delay` 64 times with a wide + // jitter band and assert we see at least two distinct values. + let cfg = RetriesConfig { + enabled: true, + max_attempts: 3, + base_delay_ms: 1000, + factor: 1.0, + jitter: 1.0, + }; + let mut samples = std::collections::HashSet::new(); + for _ in 0..64 { + samples.insert(compute_backoff_delay(0, &cfg).as_millis()); + // Tiny pause so the wall-clock sub-second component + // advances between samples in fast CI environments. + std::thread::sleep(std::time::Duration::from_micros(50)); + } + assert!( + samples.len() > 1, + "expected variance in jitter samples, got {samples:?}", + ); + } + + #[test] + fn test_decide_retry_no_retry_flag_short_circuits() { + // `--no-retry` is the user-facing debug opt-out. Mirrors the + // PR description's open design question: yes, full opt-out + // even for network errors so users can debug. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, /*no_retry=*/ true); + assert!(d.is_none(), "--no-retry disables all retries"); + } + + #[test] + fn test_decide_retry_disabled_config_no_retry() { + let cfg = RetriesConfig::disabled(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_none(), "disabled config never retries"); + } + + #[test] + fn test_decide_retry_max_attempts_cap() { + let cfg = RetriesConfig { + enabled: true, + max_attempts: 3, + base_delay_ms: 1, + factor: 1.0, + jitter: 0.0, + }; + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + // attempt 0 -> retry (allowed) + assert!(decide_retry(0, &outcome, &cfg, "GET", false, false).is_some()); + // attempt 1 -> retry (allowed) + assert!(decide_retry(1, &outcome, &cfg, "GET", false, false).is_some()); + // attempt 2 -> done; we've used all 3 attempts. Stop. + assert!(decide_retry(2, &outcome, &cfg, "GET", false, false).is_none()); + // attempt 3+ -> never. Defensive. + assert!(decide_retry(3, &outcome, &cfg, "GET", false, false).is_none()); + } + + #[test] + fn test_decide_retry_retryable_status_get_retries() { + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_some()); + } + + #[test] + fn test_decide_retry_non_retryable_status_no_retry() { + // 401 Unauthorized never retries \u2014 wait won't make creds valid. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(401), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_none()); + } + + #[test] + fn test_decide_retry_post_503_without_idempotent_no_retry() { + // Plain POST got 503 \u2014 the server may have processed it. + // Don't retry without an explicit idempotent marker. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", false, false); + assert!(d.is_none()); + } + + #[test] + fn test_decide_retry_post_503_with_idempotent_retries() { + // POST marked idempotent (x-fern-idempotent) is safe to retry. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", true, false); + assert!(d.is_some()); + } + + #[test] + fn test_decide_retry_post_429_always_safe() { + // 429 means the server *didn't* process the request \u2014 always + // safe to retry regardless of method idempotency. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(429), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", false, false); + assert!(d.is_some(), "429 retries on non-idempotent methods"); + } + + #[test] + fn test_decide_retry_network_error_get_retries() { + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_some()); + } + + #[test] + fn test_decide_retry_network_error_post_without_idempotent_no_retry() { + // Network failure on a POST: ambiguous whether the server got + // the request. Mirror the per-method policy here too. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", false, false); + assert!(d.is_none()); + } + + #[test] + fn test_decide_retry_honors_retry_after_numeric() { + // When the server provides Retry-After, honor it instead of + // the computed backoff (the server knows better than we do). + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: Some("7"), + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false) + .expect("should retry"); + assert_eq!(d, std::time::Duration::from_secs(7)); + } + + #[test] + fn test_decide_retry_falls_back_to_backoff_when_retry_after_invalid() { + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: Some("not-a-number"), + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false) + .expect("should retry"); + // Falls back to backoff math \u2014 not zero, not the parsed value. + assert!(d > std::time::Duration::ZERO); + } + + #[test] + fn test_binary_body_source_plain_path() { + match BinaryBodySource::parse("/tmp/audio.mp3") { + BinaryBodySource::File(p) => assert_eq!(p, "/tmp/audio.mp3"), + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_binary_body_source_at_path_strips_prefix() { + match BinaryBodySource::parse("@/tmp/audio.mp3") { + BinaryBodySource::File(p) => assert_eq!(p, "/tmp/audio.mp3"), + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_binary_body_source_dash_is_stdin() { + assert!(matches!(BinaryBodySource::parse("-"), BinaryBodySource::Stdin)); + } + + #[test] + fn test_binary_body_source_at_dash_is_stdin() { + // curl's spelling for stdin is `@-`; we accept it as an alias for `-`. + assert!(matches!(BinaryBodySource::parse("@-"), BinaryBodySource::Stdin)); + } + + #[test] + fn test_binary_body_source_double_at_is_literal_at_path() { + // Only the first `@` is stripped — matches curl's behavior for filenames + // that legitimately start with `@`. + match BinaryBodySource::parse("@@weird-name.mp3") { + BinaryBodySource::File(p) => assert_eq!(p, "@weird-name.mp3"), + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_header_params_not_in_query_string() { + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "user_id".to_string(), + MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + parameters.insert( + "X-Custom-Header".to_string(), + MethodParameter { + location: Some("header".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "limit".to_string(), + MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "GET".to_string(), + path: "users/{user_id}".to_string(), + parameters, + parameter_order: vec!["user_id".to_string()], + ..Default::default() + }; + + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = + r#"{"user_id": "123", "X-Custom-Header": "my-value", "limit": "10"}"#; + let input = + parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[]).unwrap(); + + // Header param should be in header_params + assert_eq!(input.header_params.len(), 1); + assert_eq!(input.header_params[0].0, "X-Custom-Header"); + assert_eq!(input.header_params[0].1, "my-value"); + + // Header param should NOT be in query_params + assert!( + !input + .query_params + .iter() + .any(|(k, _)| k == "X-Custom-Header"), + "Header param should not appear in query_params" + ); + + // Query param should still be in query_params + assert!( + input.query_params.iter().any(|(k, _)| k == "limit"), + "Query param should appear in query_params" + ); + } + + #[tokio::test] + async fn test_header_params_sent_as_http_headers() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "users".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/users".to_string(), + body: None, + query_params: Vec::new(), + header_params: vec![( + "X-Custom-Header".to_string(), + "header-value".to_string(), + )], + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert_eq!( + built + .headers() + .get("X-Custom-Header") + .map(|v| v.to_str().unwrap()), + Some("header-value"), + "Header params should be sent as HTTP headers" + ); + assert_eq!( + built.headers().get("Accept").map(|v| v.to_str().unwrap()), + Some("application/json"), + "Default Accept prefers JSON for content negotiation" + ); + } + + #[tokio::test] + async fn test_default_accept_skipped_when_accept_in_params() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "users".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/users".to_string(), + body: None, + query_params: Vec::new(), + header_params: vec![("Accept".to_string(), "application/xml".to_string())], + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert_eq!( + built.headers().get("Accept").map(|v| v.to_str().unwrap()), + Some("application/xml"), + "Explicit Accept in header params should not be overridden" + ); + } + + #[tokio::test] + async fn test_explicit_anonymous_endpoint_skips_auth() { + // `security: []` on an operation means "this endpoint is explicitly + // unauthenticated" — the executor must not attach credentials even + // when a credential-bearing provider is configured. Regression for + // the leaf/Any/All path: only RoutingAuthProvider honored this + // before; now the executor short-circuits universally. + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "public/ping".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/public/ping".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + // A bare bearer leaf — would normally attach Authorization. + let provider: crate::auth::DynAuthProvider = std::sync::Arc::new( + crate::auth::BearerAuthProvider::new( + "bearerAuth", + crate::auth::AuthCredentialSource::literal("tok"), + ), + ); + + let request = build_http_request( + &client, + &method, + &input, + &provider, + &EndpointAuthMetadata::explicit_anonymous(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert!( + built.headers().get(reqwest::header::AUTHORIZATION).is_none(), + "security: [] must opt out of auth even with a bearer provider" + ); + } + + #[test] + fn test_coerce_body_param_value_scalar_types() { + // CLI flags arrive as Value::String; coerce them per the schema's type. + assert_eq!( + coerce_body_param_value(&Value::String("42".into()), Some("integer")).unwrap(), + json!(42) + ); + assert_eq!( + coerce_body_param_value(&Value::String("2.5".into()), Some("number")).unwrap(), + json!(2.5) + ); + assert_eq!( + coerce_body_param_value(&Value::String("true".into()), Some("boolean")).unwrap(), + Value::Bool(true) + ); + assert_eq!( + coerce_body_param_value(&Value::String("false".into()), Some("boolean")).unwrap(), + Value::Bool(false) + ); + // String type passes through unchanged. + assert_eq!( + coerce_body_param_value(&Value::String("hello".into()), Some("string")).unwrap(), + json!("hello") + ); + // Already-typed values from `--params` JSON pass through. + assert_eq!( + coerce_body_param_value(&json!(99), Some("integer")).unwrap(), + json!(99) + ); + } + + #[test] + fn test_coerce_body_param_value_nested_decodes_json() { + // Object/array body fields accept a JSON string from the CLI flag. + let arr = coerce_body_param_value( + &Value::String(r#"["a","b"]"#.into()), + Some("array"), + ) + .unwrap(); + assert_eq!(arr, json!(["a", "b"])); + + let obj = coerce_body_param_value( + &Value::String(r#"{"city":"SF"}"#.into()), + Some("object"), + ) + .unwrap(); + assert_eq!(obj, json!({ "city": "SF" })); + } + + #[test] + fn test_coerce_body_param_value_rejects_bad_input() { + let err = coerce_body_param_value( + &Value::String("not-an-int".into()), + Some("integer"), + ) + .unwrap_err(); + match err { + CliError::Validation(msg) => assert!(msg.contains("Invalid integer")), + _ => panic!("Expected Validation error"), + } + + let err = coerce_body_param_value( + &Value::String("yes".into()), + Some("boolean"), + ) + .unwrap_err(); + match err { + CliError::Validation(msg) => assert!(msg.contains("Invalid boolean")), + _ => panic!("Expected Validation error"), + } + } + + #[test] + fn test_body_params_merge_into_body_via_params_json() { + // `--params` is the JSON-blob fallback that mirrors per-flag values; + // body-located params should land in the JSON body, not the query string. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "count".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("integer".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = r#"{"name": "Acme", "count": "3"}"#; + let input = parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[]) + .unwrap(); + + // Body must contain both fields, with `count` coerced to a JSON integer. + let body = input.body.expect("body should be populated from body params"); + assert_eq!(body, json!({ "name": "Acme", "count": 3 })); + + // Body fields must NOT bleed into the query string or headers. + assert!(input.query_params.is_empty(), "no query params expected"); + assert!(input.header_params.is_empty(), "no header params expected"); + } + + #[test] + fn test_json_flag_overrides_body_field_flags() { + // When both per-field flags AND `--json` are set, `--json` wins on + // overlapping keys (mirrors `--params` overriding individual flags). + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "description".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = r#"{"name": "from-flag", "description": "kept-from-flag"}"#; + let body_json = r#"{"name": "from-json"}"#; + let input = parse_and_validate_inputs( + &doc, + &method, + Some(params_json), + Some(body_json), + false, + None, + &[], + ) + .unwrap(); + + let body = input.body.expect("body should be populated"); + assert_eq!( + body, + json!({ "name": "from-json", "description": "kept-from-flag" }), + "--json overrides overlapping per-field values, leaves the rest alone" + ); + } + + #[test] + fn test_required_body_field_missing_mentions_flag_json_and_params() { + // A required body field that isn't supplied at all should produce a + // validation error that names the per-field flag, --json, and + // --params, so the user knows every way to fix it. The previous + // message only mentioned --params, which was misleading once + // per-field body flags shipped. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("'name'"), "error names the missing field: {msg}"); + assert!(msg.contains("--name"), "error names the per-field flag: {msg}"); + assert!(msg.contains("--json"), "error names --json for body fields: {msg}"); + assert!(msg.contains("--params"), "error names --params: {msg}"); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_required_param_missing_uses_flag_name_override() { + // When a parameter has `flag_name_override` set (e.g. synthetic + // idempotency-key flags inject the wire name verbatim), the error + // message must suggest THAT flag — not a kebab of the wire name. + // Otherwise the suggestion points at a flag the user can't pass. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "Idempotency-Key".to_string(), + MethodParameter { + location: Some("header".to_string()), + param_type: Some("string".to_string()), + required: true, + flag_name_override: Some("idempotency-key".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("--idempotency-key"), + "error must point at the actual flag name from flag_name_override: {msg}" + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_non_object_json_replaces_body_and_drops_per_field_flags() { + // A top-level non-object `--json` (array/scalar) has no shape to + // merge per-field flag values into, so flags are dropped and the + // `--json` payload is used wholesale. Lock in that behavior so + // future refactors of the body-assembly match don't accidentally + // start emitting nonsense (e.g. wrapping the array in an object + // with the flag values). + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + // `--name` is supplied via params; `--json` is a bare array. + let params_json = r#"{"name": "from-flag-loses"}"#; + let body_json = r#"[1, 2, 3]"#; + let input = parse_and_validate_inputs( + &doc, + &method, + Some(params_json), + Some(body_json), + false, + None, + &[], + ) + .unwrap(); + + let body = input.body.expect("body should be populated"); + assert_eq!( + body, + json!([1, 2, 3]), + "non-object --json must replace the body wholesale, not be merged" + ); + } + + #[test] + fn test_required_non_body_param_missing_omits_json_hint() { + // The --json hint is body-specific. A missing required query/path/ + // header param should NOT suggest --json — it would mislead the + // user into thinking the body matters here. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "limit".to_string(), + MethodParameter { + location: Some("query".to_string()), + param_type: Some("integer".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "GET".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("--limit"), "error names the per-field flag: {msg}"); + assert!(msg.contains("--params"), "error names --params: {msg}"); + assert!(!msg.contains("--json"), "non-body error should not mention --json: {msg}"); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_per_field_body_flags_path_runs_schema_validation() { + // Schema validation must run regardless of whether the body was + // built from --json or from per-field flags. The previous version + // only validated on the --json path, letting flag-only bodies skip + // schema checks even though clap-typed strings are more likely to + // produce shape mismatches than hand-written JSON. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + // Schema declares `name` as an integer — a string value from the + // per-field flag should be rejected by the schema validator. + let mut schema_props = std::collections::HashMap::new(); + schema_props.insert( + "name".to_string(), + crate::openapi::discovery::JsonSchemaProperty { + prop_type: Some("integer".to_string()), + ..Default::default() + }, + ); + let mut schemas = std::collections::HashMap::new(); + schemas.insert( + "ThingRequest".to_string(), + crate::openapi::discovery::JsonSchema { + schema_type: Some("object".to_string()), + properties: schema_props, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + request: Some(crate::openapi::discovery::SchemaRef { + schema_ref: Some("ThingRequest".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + schemas, + ..Default::default() + }; + + let params_json = r#"{"name": "not-an-integer"}"#; + let err = parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("schema validation"), + "schema validator should fire on flag-only body: {msg}" + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_pagination_config_default() { + let config = PaginationConfig::default(); + assert!(!config.page_all); + assert_eq!(config.page_limit, 10); + assert_eq!(config.page_delay_ms, 100); + } + + #[test] + fn test_mime_to_extension_more_types() { + assert_eq!(mime_to_extension("text/plain"), "txt"); + assert_eq!(mime_to_extension("text/csv"), "csv"); + assert_eq!(mime_to_extension("application/zip"), "zip"); + assert_eq!(mime_to_extension("application/xml"), "xml"); + assert_eq!(mime_to_extension("text/html"), "html"); + assert_eq!(mime_to_extension("application/json"), "bin"); // Default for unknown specific json types if not scripts + assert_eq!( + mime_to_extension("application/vnd.google-apps.script"), + "json" + ); + assert_eq!( + mime_to_extension("application/vnd.google-apps.presentation"), + "pptx" + ); + } + + #[test] + fn test_validate_body_valid() { + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let mut schemas = HashMap::new(); + schemas.insert( + "File".to_string(), + JsonSchema { + properties, + ..Default::default() + }, + ); + + let doc = RestDescription { + schemas, + ..Default::default() + }; + + let body = json!({ "name": "My File" }); + assert!(validate_body_against_schema(&body, "File", &doc).is_ok()); + } + + #[test] + fn test_validate_body_open_schema_allows_any_properties() { + // A schema with type=object but no properties defined is an open schema: + // any properties are allowed (JSON Schema default). + let schemas = HashMap::from([( + "Body".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: HashMap::new(), + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ "name": "foo", "count": 3, "nested": {"x": 1} }); + assert!( + validate_body_against_schema(&body, "Body", &doc).is_ok(), + "open object schema should accept any properties" + ); + } + + #[test] + fn test_validate_body_unknown_field() { + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let mut schemas = HashMap::new(); + schemas.insert( + "File".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + ); + + let doc = RestDescription { + schemas, + ..Default::default() + }; + + let body = json!({ "name": "My File", "invalidField": 123 }); + let result = validate_body_against_schema(&body, "File", &doc); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unknown property")); + } + + #[test] + fn test_validate_body_deep_validation() { + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + properties.insert( + "status".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + enum_values: Some(vec!["ACTIVE".to_string(), "INACTIVE".to_string()]), + ..Default::default() + }, + ); + properties.insert( + "count".to_string(), + JsonSchemaProperty { + prop_type: Some("integer".to_string()), + ..Default::default() + }, + ); + properties.insert( + "tags".to_string(), + JsonSchemaProperty { + prop_type: Some("array".to_string()), + items: Some(Box::new(JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + })), + ..Default::default() + }, + ); + properties.insert( + "parent".to_string(), + JsonSchemaProperty { + schema_ref: Some("Parent".to_string()), + ..Default::default() + }, + ); + + let mut parent_props = HashMap::new(); + parent_props.insert( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let mut schemas = HashMap::new(); + schemas.insert( + "File".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + required: vec!["name".to_string(), "status".to_string()], + properties, + ..Default::default() + }, + ); + schemas.insert( + "Parent".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: parent_props, + ..Default::default() + }, + ); + + let doc = RestDescription { + schemas, + ..Default::default() + }; + + // Valid Request + let body = json!({ + "name": "My File", + "status": "ACTIVE", + "count": 10, + "tags": ["one", "two"], + "parent": { "id": "123" } + }); + assert!(validate_body_against_schema(&body, "File", &doc).is_ok()); + + // Missing Required Field + let body_missing = json!({ "name": "My File" }); + let err = validate_body_against_schema(&body_missing, "File", &doc).unwrap_err(); + assert!(err + .to_string() + .contains("Missing required property 'status'")); + + // Invalid Enum Value + let body_bad_enum = json!({ "name": "My File", "status": "UNKNOWN" }); + let err = validate_body_against_schema(&body_bad_enum, "File", &doc).unwrap_err(); + assert!(err.to_string().contains("not a valid enum member")); + + // Invalid Type + let body_bad_type = json!({ "name": "My File", "status": "ACTIVE", "count": "10" }); + let err = validate_body_against_schema(&body_bad_type, "File", &doc).unwrap_err(); + assert!(err + .to_string() + .contains("Expected type 'integer', found string")); + + // Deep Schema Reference Validation Failure + let body_bad_ref = json!({ + "name": "My File", + "status": "ACTIVE", + "parent": { "invalidField": "123" } + }); + let err = validate_body_against_schema(&body_bad_ref, "File", &doc).unwrap_err(); + assert!(err.to_string().contains("Unknown property")); + + // Expected Object Type Failure + let body_not_object = json!([]); + let err = validate_body_against_schema(&body_not_object, "File", &doc).unwrap_err(); + assert!(err.to_string().contains("Expected object")); + } + #[tokio::test] + async fn test_build_multipart_body() { + let metadata = Some(json!({ "name": "test.txt", "mimeType": "text/plain" })); + let content = b"Hello world"; + + let (body, content_type) = build_multipart_body(&metadata, content, "text/plain").unwrap(); + + // Check content type has boundary + assert!(content_type.starts_with("multipart/related; boundary=")); + let boundary = content_type.split("boundary=").nth(1).unwrap(); + + let body_str = String::from_utf8(body).unwrap(); + + // Verify structure + assert!(body_str.contains(boundary)); + assert!(body_str.contains("Content-Type: application/json")); + assert!(body_str.contains("{\"mimeType\":\"text/plain\",\"name\":\"test.txt\"}")); + assert!(body_str.contains("Content-Type: text/plain")); + assert!(body_str.contains("Hello world")); + } + + #[tokio::test] + async fn test_build_multipart_body_no_metadata() { + let metadata = None; + let content = b"Binary data"; + + let (body, content_type) = + build_multipart_body(&metadata, content, "application/octet-stream").unwrap(); + let boundary = content_type.split("boundary=").nth(1).unwrap(); + let body_str = String::from_utf8(body).unwrap(); + + assert!(body_str.contains(boundary)); + assert!(body_str.contains("application/octet-stream")); + assert!(body_str.contains("Binary data")); + } + + #[test] + fn test_resolve_upload_mime_explicit_flag() { + let metadata = Some(json!({ "mimeType": "image/png" })); + let mime = resolve_upload_mime(Some("text/markdown"), Some("file.txt"), &metadata); + assert_eq!(mime, "text/markdown", "explicit flag takes top priority"); + } + + #[test] + fn test_resolve_upload_mime_extension_beats_metadata() { + let metadata = Some(json!({ "mimeType": "application/vnd.google-apps.document" })); + let mime = resolve_upload_mime(None, Some("notes.md"), &metadata); + assert_eq!( + mime, "text/markdown", + "extension inference ranks above metadata mimeType" + ); + } + + #[test] + fn test_resolve_upload_mime_metadata_fallback_for_unknown_extension() { + let metadata = Some(json!({ "mimeType": "text/plain" })); + let mime = resolve_upload_mime(None, Some("file.unknown"), &metadata); + assert_eq!( + mime, "text/plain", + "metadata mimeType is used when extension is unrecognized" + ); + } + + #[test] + fn test_resolve_upload_mime_extension_when_no_metadata() { + let mime = resolve_upload_mime(None, Some("notes.md"), &None); + assert_eq!(mime, "text/markdown"); + + let mime = resolve_upload_mime(None, Some("page.html"), &None); + assert_eq!(mime, "text/html"); + + let mime = resolve_upload_mime(None, Some("data.csv"), &None); + assert_eq!(mime, "text/csv"); + } + + #[test] + fn test_resolve_upload_mime_fallback() { + let mime = resolve_upload_mime(None, Some("file.unknown"), &None); + assert_eq!(mime, "application/octet-stream"); + } + + #[test] + fn test_resolve_upload_mime_explicit_enables_import_conversion() { + let metadata = Some(json!({ "mimeType": "application/vnd.google-apps.document" })); + let mime = resolve_upload_mime(Some("text/markdown"), Some("impact.md"), &metadata); + assert_eq!( + mime, "text/markdown", + "--upload-content-type overrides metadata for media part" + ); + } + + #[test] + fn test_build_multipart_bytes_with_metadata() { + let metadata = Some(json!({ "threadId": "thread-123" })); + let data = b"From: test@example.com\r\nSubject: Test\r\n\r\nBody"; + let (_, content_type, content_length) = + build_multipart_bytes(&metadata, data, "message/rfc822").unwrap(); + + assert!( + content_type.starts_with("multipart/related; boundary=fern_boundary_"), + "content_type should be multipart/related: {content_type}", + ); + // Content-length should cover: preamble + data + postamble + assert!( + content_length > data.len() as u64, + "content_length should exceed raw data size: {content_length}", + ); + } + + #[test] + fn test_build_multipart_bytes_without_metadata() { + let (_, content_type, content_length) = + build_multipart_bytes(&None, b"test body", "message/rfc822").unwrap(); + + assert!(content_type.starts_with("multipart/related; boundary=")); + assert!(content_length > 0); + } + + #[tokio::test] + async fn test_build_multipart_stream_content_length() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("small.txt"); + let file_content = b"Hello stream"; + std::fs::write(&file_path, file_content).unwrap(); + + let metadata_value = json!({ "name": "small.txt" }); + let metadata = Some(metadata_value.clone()); + let file_size = file_content.len() as u64; + + let (_body, content_type, declared_len) = build_multipart_stream( + &metadata, + file_path.to_str().unwrap(), + file_size, + "text/plain", + ) + .unwrap(); + + assert!(content_type.starts_with("multipart/related; boundary=")); + let boundary = content_type.split("boundary=").nth(1).unwrap(); + + // Manually compute expected content length: + // preamble = "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{json}\r\n--{boundary}\r\nContent-Type: text/plain\r\n\r\n" + // postamble = "\r\n--{boundary}--\r\n" + let metadata_json = serde_json::to_string(&metadata_value).unwrap(); + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\ + --{boundary}\r\nContent-Type: text/plain\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + let expected = preamble.len() as u64 + file_size + postamble.len() as u64; + assert_eq!( + declared_len, expected, + "declared Content-Length must match expected preamble + file + postamble" + ); + } + + #[tokio::test] + async fn test_build_multipart_stream_large_file() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("large.bin"); + // 256 KB — larger than the default 64 KB ReaderStream chunk size + let data = vec![0xABu8; 256 * 1024]; + std::fs::write(&file_path, &data).unwrap(); + + let metadata = None; + let file_size = data.len() as u64; + + let (_body, _content_type, declared_len) = build_multipart_stream( + &metadata, + file_path.to_str().unwrap(), + file_size, + "application/octet-stream", + ) + .unwrap(); + + // Content-Length must account for the empty-metadata preamble + large file + postamble + assert!( + declared_len > file_size, + "Content-Length ({declared_len}) must be larger than file size ({file_size}) due to multipart framing" + ); + + // Verify exact arithmetic: preamble overhead + file_size + postamble + let boundary = _content_type.split("boundary=").nth(1).unwrap(); + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{{}}\r\n\ + --{boundary}\r\nContent-Type: application/octet-stream\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + let expected = preamble.len() as u64 + file_size + postamble.len() as u64; + assert_eq!( + declared_len, expected, + "Content-Length must match for multi-chunk files" + ); + } + + #[test] + fn test_build_url_basic() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + flat_path: Some("files".to_string()), + ..Default::default() + }; + let params = Map::new(); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/files"); + } + + #[test] + fn test_build_url_override_replaces_spec_base() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + // Use a leading-slash path matching real OpenAPI spec output + let method = RestMethod { + path: "/files".to_string(), + flat_path: Some("/files".to_string()), + ..Default::default() + }; + let params = Map::new(); + + let (url, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000")).unwrap(); + assert_eq!(url, "http://localhost:9000/files"); + } + + #[test] + fn test_build_url_override_trailing_slash_normalized() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + // Use a leading-slash path matching real OpenAPI spec output + let method = RestMethod { + path: "/users/me".to_string(), + flat_path: Some("/users/me".to_string()), + ..Default::default() + }; + let params = Map::new(); + + // With trailing slash on override + let (url_with, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000/")).unwrap(); + // Without trailing slash on override + let (url_without, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000")).unwrap(); + assert_eq!(url_with, url_without); + assert_eq!(url_with, "http://localhost:9000/users/me"); + } + + #[test] + fn test_build_url_override_no_double_slash_with_leading_slash_path() { + // Regression test: OpenAPI paths start with /, override must not produce // + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/users/me".to_string(), + flat_path: Some("/users/me".to_string()), + ..Default::default() + }; + let params = Map::new(); + + let (url, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000")).unwrap(); + assert_eq!(url, "http://localhost:9000/users/me"); + } + + // ----------------------------------------------------------------------- + // x-fern-base-path + // + // Exhaustive 2x2 matrix over the spec's `x-fern-base-path` value + // (with/without leading slash) and the base URL's trailing slash. The + // wire tests in tests/openapi_fixture_wire.rs exercise the same matrix + // end-to-end through the HTTP stack. + // ----------------------------------------------------------------------- + + fn base_path_doc(base_path: &str) -> RestDescription { + RestDescription { + base_path: Some(base_path.to_string()), + ..Default::default() + } + } + + fn things_method() -> RestMethod { + RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + } + } + + #[test] + fn test_build_url_base_path_leading_slash_x_server_trailing_slash() { + let doc = base_path_doc("/v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example/"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_leading_slash_x_server_no_trailing_slash() { + let doc = base_path_doc("/v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_no_leading_slash_x_server_trailing_slash() { + let doc = base_path_doc("v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example/"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_no_leading_slash_x_server_no_trailing_slash() { + let doc = base_path_doc("v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_applies_to_spec_root_url() { + // No base_url override, no doc.base_url — base_path applies on top + // of the effective root_url (which is what OpenAPI's `servers[0].url` + // becomes after parsing). + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/api/public".to_string()), + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/api/public/things"); + } + + #[test] + fn test_build_url_base_path_composes_with_per_operation_server() { + // Per-operation `servers[]` override is captured in `method.root_url` + // by the parser. `effective_root_url` returns it (taking precedence + // over the spec-level `doc.root_url`), and `apply_base_path` then + // prepends the base path on top of the per-op server. This test + // pins that composition — without it, a per-op upload-host override + // would silently lose the base path prefix. + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/uploads".to_string(), + flat_path: Some("/uploads".to_string()), + root_url: "https://upload.example.com".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://upload.example.com/v1/uploads"); + } + + #[test] + fn test_build_url_base_path_per_op_server_with_trailing_slash() { + // Same composition as the test above, but the per-op server URL + // carries a trailing slash — the slash-edge normalization runs at + // the per-op + base_path boundary too, not just at the doc.root_url + // + base_path boundary. + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/uploads".to_string(), + flat_path: Some("/uploads".to_string()), + root_url: "https://upload.example.com/".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://upload.example.com/v1/uploads"); + } + + #[test] + fn test_build_url_base_path_applies_to_doc_base_url() { + // doc.base_url (set when the spec's server includes a path + // component) is also augmented by base_path. + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/things"); + } + + #[test] + fn test_build_url_base_path_with_trailing_slash_normalized() { + // Authoring quirk: `x-fern-base-path: /v1/` should not produce + // double slashes against the operation path. + let doc = base_path_doc("/v1/"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_multi_segment() { + // Multi-segment base paths (e.g. `/api/v1`) are emitted verbatim; + // only the boundary slashes against the server URL and operation + // path are normalized. + let doc = base_path_doc("/api/v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/api/v1/things"); + } + + #[test] + fn test_build_url_base_path_none_unchanged() { + // When `base_path` is None the URL is identical to the pre-feature + // behavior — this protects existing specs that don't use the + // extension from any drift. + let doc = RestDescription { + base_path: None, + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/things"); + } + + #[test] + fn test_build_url_base_path_preserves_path_substitution() { + // Path parameter substitution still happens against the operation + // path after base_path is prepended. + let doc = base_path_doc("/v1"); + let method = RestMethod { + path: "/things/{thingId}".to_string(), + flat_path: Some("/things/{thingId}".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("thingId".to_string(), json!("abc")); + let (url, _) = build_url( + &doc, + &method, + ¶ms, + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things/abc"); + } + + #[test] + fn test_apply_base_path_helper_handles_edge_cases() { + // None → base returned verbatim. + assert_eq!(apply_base_path("http://x", None), "http://x"); + assert_eq!(apply_base_path("http://x/", None), "http://x/"); + + // Empty / slash-only base_path is a no-op — the helper returns + // the base verbatim and leaves trailing-slash normalization to + // build_url's existing operation-path joining logic. + assert_eq!(apply_base_path("http://x", Some("")), "http://x"); + assert_eq!(apply_base_path("http://x", Some("/")), "http://x"); + assert_eq!(apply_base_path("http://x/", Some("/")), "http://x/"); + } + + /// `x-fern-base-path` with a templated path parameter (e.g. + /// `/{tenant}/v1`) substitutes the placeholder from the operation's + /// parameters at request time, and the consumed parameter is NOT + /// echoed in the query string. Mirrors upstream Fern's behavior of + /// baking the base path into endpoint paths at Definition build + /// time and resolving placeholders uniformly with the rest of the + /// path-parameter renderer. + #[test] + fn test_build_url_base_path_templated_param_substitutes_and_does_not_leak_to_query() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/acme/v1/things"); + assert!(qs.is_empty(), "tenant must be consumed by base_path, not leaked as query: {qs:?}"); + } + + /// Multi-placeholder base paths (e.g. `/{region}/{tenant}/v1`) are + /// rendered uniformly; both placeholder params are consumed by the + /// URL path and neither leaks to the query string. + #[test] + fn test_build_url_base_path_multi_templated_params_substitute() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{region}/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("region".to_string(), json!("us-east-1")); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/us-east-1/acme/v1/things"); + assert!(qs.is_empty(), "both placeholders must be consumed: {qs:?}"); + } + + /// A templated base path composes with operation-level path + /// parameters: the base path placeholder and the endpoint path + /// placeholder both substitute, and only non-path params survive + /// as query string entries. + #[test] + fn test_build_url_base_path_templated_with_operation_path_param_and_query() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let mut method_params: HashMap = + HashMap::new(); + method_params.insert( + "tenant".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }, + ); + method_params.insert( + "id".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + method_params.insert( + "verbose".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "/things/{id}".to_string(), + flat_path: Some("/things/{id}".to_string()), + parameters: method_params, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + params.insert("id".to_string(), json!("thing-1")); + params.insert("verbose".to_string(), json!("true")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/acme/v1/things/thing-1"); + assert_eq!(qs, vec![("verbose".to_string(), "true".to_string())]); + } + + /// A templated base path composes additively with `--base-url` + /// override, just like a literal base path does. The override + /// supplies the host; the templated base path still applies. + #[test] + fn test_build_url_base_path_templated_param_with_base_url_override() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, Some("https://staging.example.com")).unwrap(); + assert_eq!(url, "https://staging.example.com/acme/v1/things"); + assert!(qs.is_empty()); + } + + /// A param declared as `in: path` on the operation but whose + /// placeholder lives only in `x-fern-base-path` (not in the + /// operation's URL template) must NOT trigger the "path parameter + /// not in URL template" validation error — it's still a path + /// parameter, just one that the base path consumes. This is the + /// most natural customer pattern when their OpenAPI declares a + /// shared prefix param like `{tenant}` at the path-item level. + #[test] + fn test_build_url_base_path_templated_param_declared_as_path_param_does_not_error() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let mut method_params: HashMap = + HashMap::new(); + method_params.insert( + "tenant".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + parameters: method_params, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/acme/v1/things"); + assert!(qs.is_empty()); + } + + /// When a placeholder in `x-fern-base-path` has no corresponding + /// parameter, the placeholder is left literal in the URL — same + /// fallback behavior as `render_path_template` on endpoint paths. + /// This avoids a hard error for partial fills (e.g. callers that + /// stub the base path) while still making the missing param + /// visible in the outgoing URL. + #[test] + fn test_build_url_base_path_templated_param_missing_value_leaves_placeholder() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let (url, qs) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/{tenant}/v1/things"); + assert!(qs.is_empty()); + } + + /// `doc.base_url` with a *path component* (i.e. the spec's + /// `servers[].url` includes a path) composes with `base_path` — + /// `apply_base_path` doesn't care whether the base is a bare host or + /// host+path; it just joins with one slash. + #[test] + fn test_build_url_base_path_doc_base_url_with_path_component() { + let doc = RestDescription { + base_url: Some("https://api.example.com/v2".to_string()), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v2/v1/things"); + } + + /// `base_path` is applied uniformly to the `is_upload` codepath too, + /// not just to the regular path. Currently unreachable for OpenAPI + /// specs (the OpenAPI parser never populates `media_upload`), but + /// pinning the wiring makes the code self-consistent — if a future + /// change ever exposes media uploads to OpenAPI, base_path won't + /// silently be dropped. + #[test] + fn test_build_url_base_path_applies_to_media_upload_branch() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/files".to_string(), + flat_path: Some("/files".to_string()), + supports_media_upload: true, + media_upload: Some(crate::openapi::discovery::MediaUpload { + protocols: Some(crate::openapi::discovery::MediaUploadProtocols { + simple: Some(crate::openapi::discovery::MediaUploadProtocol { + path: "/upload/files".to_string(), + ..Default::default() + }), + }), + ..Default::default() + }), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), true, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/upload/files"); + } + + #[test] + fn test_build_url_substitution() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "files/{fileId}".to_string(), + flat_path: Some("files/{fileId}".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("fileId".to_string(), json!("123")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/files/123"); + } + + #[test] + fn test_build_url_query_params() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + flat_path: Some("files".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("q".to_string(), json!("search term")); + + let (url, query) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/files"); + assert_eq!(query, vec![("q".to_string(), "search term".to_string())]); + } + + #[test] + fn test_build_url_repeated_query_param_expands_array() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut method_params = HashMap::new(); + method_params.insert( + "metadataHeaders".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + repeated: true, + ..Default::default() + }, + ); + let method = RestMethod { + path: "messages".to_string(), + flat_path: Some("messages".to_string()), + parameters: method_params, + ..Default::default() + }; + let mut params = Map::new(); + params.insert( + "metadataHeaders".to_string(), + json!(["Subject", "Date", "From"]), + ); + + let (_url, query) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + query, + vec![ + ("metadataHeaders".to_string(), "Subject".to_string()), + ("metadataHeaders".to_string(), "Date".to_string()), + ("metadataHeaders".to_string(), "From".to_string()), + ] + ); + } + + #[test] + fn test_build_url_encodes_path_parameter_chars() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "spreadsheetId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "range".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "spreadsheets/{spreadsheetId}/values/{range}".to_string(), + flat_path: Some("spreadsheets/{spreadsheetId}/values/{range}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("spreadsheetId".to_string(), json!("abc123")); + params.insert("range".to_string(), json!("hash#1!A1:B2")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + url, + "https://api.example.com/spreadsheets/abc123/values/hash%231%21A1%3AB2" + ); + } + + #[test] + fn test_build_url_plus_expansion_preserves_slashes() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "name".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/{+name}".to_string(), + flat_path: Some("v1/{+name}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert( + "name".to_string(), + json!("projects/p1/locations/us/topics/t1"), + ); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + url, + "https://api.example.com/v1/projects/p1/locations/us/topics/t1" + ); + } + + #[test] + fn test_build_url_plus_expansion_rejects_reserved_chars() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "name".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/{+name}".to_string(), + flat_path: Some("v1/{+name}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("name".to_string(), json!("projects/p1#frag?x=y")); + + let err = build_url(&doc, &method, ¶ms, false, None).unwrap_err(); + assert!(err.to_string().contains("must not contain '?' or '#'")); + } + + #[test] + fn test_build_url_plus_expansion_rejects_path_traversal() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "name".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/{+name}".to_string(), + flat_path: Some("v1/{+name}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("name".to_string(), json!("projects/../../etc/passwd")); + + let err = build_url(&doc, &method, ¶ms, false, None).unwrap_err(); + assert!(err.to_string().contains("path traversal")); + } + + #[test] + fn test_build_url_upload_endpoint_substitutes_path_params() { + let doc = RestDescription { + root_url: "https://www.googleapis.com/".to_string(), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "drive/v3/files/{fileId}".to_string(), + flat_path: Some("drive/v3/files/{fileId}".to_string()), + parameters, + media_upload: Some(crate::openapi::discovery::MediaUpload { + protocols: Some(crate::openapi::discovery::MediaUploadProtocols { + simple: Some(crate::openapi::discovery::MediaUploadProtocol { + path: "/upload/drive/v3/files/{fileId}".to_string(), + multipart: Some(true), + }), + }), + ..Default::default() + }), + ..Default::default() + }; + + let mut params = Map::new(); + params.insert("fileId".to_string(), json!("abc/123")); + + let (url, _) = build_url(&doc, &method, ¶ms, true, None).unwrap(); + assert_eq!( + url, + "https://www.googleapis.com/upload/drive/v3/files/abc%2F123" + ); + } + + #[test] + fn test_build_url_does_not_replace_placeholder_like_values() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "v1/{parent}/{child}".to_string(), + flat_path: Some("v1/{parent}/{child}".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("parent".to_string(), json!("literal-{child}-value")); + params.insert("child".to_string(), json!("ok")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + url, + "https://api.example.com/v1/literal-%7Bchild%7D-value/ok" + ); + } + + #[test] + fn test_build_url_errors_for_path_param_not_in_template() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "files".to_string(), + flat_path: Some("files".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("fileId".to_string(), json!("123")); + + let err = build_url(&doc, &method, ¶ms, false, None).unwrap_err(); + assert!(err + .to_string() + .contains("Path parameter 'fileId' was provided but is not present")); + } + + #[test] + fn test_build_url_flatpath_fallback_on_mismatch() { + // Reproduces the Slides presentations.get bug where flatPath uses + // {presentationsId} (plural) but the parameter is presentationId (singular). + let doc = RestDescription { + base_url: Some("https://slides.googleapis.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "presentationId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/presentations/{+presentationId}".to_string(), + flat_path: Some("v1/presentations/{presentationsId}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("presentationId".to_string(), json!("abc123")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://slides.googleapis.com/v1/presentations/abc123"); + } + + #[test] + fn test_serialize_deep_object() { + let value = json!({"status": "active", "date": "2024-01-01"}); + let result = serialize_query_param( + "filter", + &value, + Some(&MethodParameter { + style: Some("deepObject".to_string()), + ..Default::default() + }), + ); + assert!(result.contains(&("filter[status]".to_string(), "active".to_string()))); + assert!(result.contains(&("filter[date]".to_string(), "2024-01-01".to_string()))); + } + + #[test] + fn test_serialize_form_explode_array() { + let value = json!(["a", "b", "c"]); + let result = serialize_query_param( + "tags", + &value, + Some(&MethodParameter { + style: Some("form".to_string()), + explode: Some(true), + ..Default::default() + }), + ); + assert_eq!( + result, + vec![ + ("tags".to_string(), "a".to_string()), + ("tags".to_string(), "b".to_string()), + ("tags".to_string(), "c".to_string()), + ] + ); + } + + #[test] + fn test_serialize_form_no_explode_array() { + let value = json!(["a", "b", "c"]); + let result = serialize_query_param( + "tags", + &value, + Some(&MethodParameter { + style: Some("form".to_string()), + explode: Some(false), + ..Default::default() + }), + ); + assert_eq!(result, vec![("tags".to_string(), "a,b,c".to_string())]); + } + + #[test] + fn test_serialize_default_style_is_form() { + // No style specified -> defaults to form with explode + let value = json!("hello"); + let result = serialize_query_param("q", &value, None); + assert_eq!(result, vec![("q".to_string(), "hello".to_string())]); + } + + #[test] + fn test_get_nested_str_simple() { + let val = json!({"nextPageToken": "tok123"}); + assert_eq!(get_nested_str(&val, "nextPageToken"), Some("tok123")); + } + + #[test] + fn test_get_nested_str_nested_path() { + let val = json!({"pagination": {"cursor": "abc"}}); + assert_eq!(get_nested_str(&val, "pagination.cursor"), Some("abc")); + } + + #[test] + fn test_get_nested_str_missing_returns_none() { + let val = json!({"other": "value"}); + assert_eq!(get_nested_str(&val, "nextPageToken"), None); + } + + #[test] + fn test_get_nested_str_non_string_returns_none() { + let val = json!({"count": 42}); + assert_eq!(get_nested_str(&val, "count"), None); + } + + // --------------------------------------------------------------- + // x-fern-sdk-return-value: dot-path resolution + // --------------------------------------------------------------- + + #[test] + fn test_get_nested_value_top_level_property() { + let val = json!({"data": [1, 2, 3], "meta": {}}); + assert_eq!(get_nested_value(&val, "data"), Some(&json!([1, 2, 3]))); + } + + #[test] + fn test_get_nested_value_nested_property() { + let val = json!({"result": {"items": ["x", "y"]}}); + assert_eq!( + get_nested_value(&val, "result.items"), + Some(&json!(["x", "y"])) + ); + } + + #[test] + fn test_get_nested_value_missing_top_returns_none() { + let val = json!({"other": 1}); + assert_eq!(get_nested_value(&val, "data"), None); + } + + #[test] + fn test_get_nested_value_missing_intermediate_returns_none() { + // First segment exists but the second doesn't — the executor + // must error rather than silently fall through. + let val = json!({"result": {"other": 1}}); + assert_eq!(get_nested_value(&val, "result.items"), None); + } + + #[test] + fn test_get_nested_value_returns_primitive_subvalue() { + // The extension is valid on a leaf primitive: e.g. an endpoint + // declaring `x-fern-sdk-return-value: id` on a wrapper response + // should surface the bare ID string. + let val = json!({"id": "abc-123", "name": "thing"}); + assert_eq!(get_nested_value(&val, "id"), Some(&json!("abc-123"))); + } + + #[test] + fn test_get_nested_value_empty_path_returns_none() { + let val = json!({"data": 1}); + assert_eq!(get_nested_value(&val, ""), None); + assert_eq!(get_nested_value(&val, " "), None); + } + + #[test] + fn test_get_nested_value_consecutive_dots_returns_none() { + // `a..b` would otherwise produce a segment lookup for the empty + // string, which always misses. Treat it explicitly as unresolved. + let val = json!({"a": {"b": 1}}); + assert_eq!(get_nested_value(&val, "a..b"), None); + } + + #[test] + fn test_get_nested_value_array_index() { + // Numeric segments index into arrays. `users.0.name` walks the + // first element of the `users` array and reads its `name`. + let val = json!({"users": [{"name": "alice"}, {"name": "bob"}]}); + assert_eq!( + get_nested_value(&val, "users.0.name"), + Some(&json!("alice")) + ); + assert_eq!( + get_nested_value(&val, "users.1.name"), + Some(&json!("bob")) + ); + } + + #[test] + fn test_get_nested_value_array_index_out_of_range_returns_none() { + let val = json!({"users": [{"name": "alice"}]}); + assert_eq!(get_nested_value(&val, "users.5.name"), None); + } + + #[test] + fn test_get_nested_value_array_index_on_object_returns_none() { + // `0` against a non-array, non-`"0"`-keyed object is a miss. + let val = json!({"users": {"alice": 1}}); + assert_eq!(get_nested_value(&val, "users.0"), None); + } + + #[test] + fn test_get_nested_value_object_key_named_zero_wins_over_array_index() { + // If an object happens to have a literal `"0"` key, prefer that + // over array indexing — the user's spec said "the property `0`", + // not "the zeroth element". We're not an array here anyway, but + // this also documents the precedence rule. + let val = json!({"0": "object-key-zero", "list": [10, 20, 30]}); + assert_eq!(get_nested_value(&val, "0"), Some(&json!("object-key-zero"))); + } + + #[test] + fn test_extract_return_value_top_level_resolves() { + let body = json!({"data": [1, 2], "meta": {"total": 2}}); + let out = extract_return_value(&body, Some("data"), false, "op").unwrap(); + assert_eq!(out, json!([1, 2])); + } + + #[test] + fn test_extract_return_value_nested_resolves() { + let body = json!({"result": {"items": [{"id": 1}]}}); + let out = extract_return_value(&body, Some("result.items"), false, "op").unwrap(); + assert_eq!(out, json!([{"id": 1}])); + } + + #[test] + fn test_extract_return_value_unresolved_path_errors() { + let body = json!({"foo": 1}); + let err = extract_return_value(&body, Some("data"), false, "things.list") + .expect_err("missing path must error"); + let msg = err.to_string(); + assert!( + msg.contains("'data'") && msg.contains("things.list"), + "error should name both path and operation id: {msg}", + ); + assert!( + msg.contains("--no-extract"), + "error should point users at the --no-extract escape hatch: {msg}", + ); + } + + #[test] + fn test_extract_return_value_no_path_returns_full_body() { + let body = json!({"data": [1], "meta": {}}); + let out = extract_return_value(&body, None, false, "op").unwrap(); + assert_eq!(out, body); + } + + #[test] + fn test_extract_return_value_no_extract_overrides_path() { + // The opt-out flag bypasses extraction entirely even when the + // spec declares a return path — used to debug responses that + // don't match the spec's promised shape. + let body = json!({"foo": 1}); + let out = extract_return_value(&body, Some("data"), true, "op") + .expect("no_extract=true must bypass extraction even if path would fail"); + assert_eq!( + out, body, + "no_extract returns the full body verbatim, including when the path would have errored", + ); + } + + #[test] + fn test_extract_return_value_resolved_null_is_preserved_not_errored() { + // `{"data": null}` + `return_value: "data"` is *not* an error — + // the spec promised a `data` field, the server delivered one, + // it just happens to be JSON null. Typed SDKs would surface + // this as a nullable response field; the CLI surfaces it as + // the literal `null`. + let body = json!({"data": null, "meta": {}}); + let out = extract_return_value(&body, Some("data"), false, "op") + .expect("resolved null is a valid extracted value"); + assert_eq!(out, json!(null)); + } + + #[test] + fn test_extract_return_value_descriptor_appears_verbatim_in_error() { + // When operationId is absent the caller passes a descriptor + // like "GET /reports". Make sure that descriptor survives the + // format string intact so users can locate the offending op. + let body = json!({"foo": 1}); + let err = extract_return_value(&body, Some("data"), false, "GET /reports") + .expect_err("missing path must error"); + let msg = err.to_string(); + assert!( + msg.contains("GET /reports"), + "descriptor must appear verbatim in error: {msg}", + ); + } + + #[test] + fn test_get_nested_value_path_through_array_with_index() { + // Composes `extract_return_value` with array indexing: paths + // like `data.0` extract the first element of an array. + let body = json!({"data": [{"id": "first"}, {"id": "second"}]}); + let out = extract_return_value(&body, Some("data.0"), false, "op").unwrap(); + assert_eq!(out, json!({"id": "first"})); + } + + #[tokio::test] + async fn test_handle_json_response_extracts_subvalue_capture() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"data":[{"id":1}],"meta":{"total":1}}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + false, + "things.list", + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(captured.len(), 1); + assert_eq!( + captured[0], + json!([{"id": 1}]), + "captured value should be the extracted subvalue, not the full body", + ); + } + + #[tokio::test] + async fn test_handle_json_response_no_extract_keeps_full_body() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let body = r#"{"data":[{"id":1}],"meta":{"total":1}}"#; + let result = handle_json_response( + body, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + true, // no_extract + "things.list", + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(captured[0], serde_json::from_str::(body).unwrap()); + } + + #[tokio::test] + async fn test_handle_json_response_extract_unresolved_errors() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let err = handle_json_response( + r#"{"foo":1}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + false, + "things.list", + ) + .await + .expect_err("unresolved extract path must surface as a validation error"); + assert!( + err.to_string().contains("'data'"), + "error message should name the missing path: {err}", + ); + assert_eq!( + pages_fetched, 0, + "errors must abort before the page counter advances", + ); + } + + #[tokio::test] + async fn test_handle_json_response_pagination_with_extract_emits_subvalue_per_page() { + // Combined behavior check: per-op cursor pagination + extract. + // The full body is still used for pagination continuation (the + // cursor lives outside the extracted subvalue), but only the + // `data` subvalue is captured for the caller. + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Cursor { + cursor: "cursor".to_string(), + next_cursor: "next".to_string(), + results: "data".to_string(), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"data":[{"id":1},{"id":2}],"next":"page-2"}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + false, + "things.list", + ) + .await + .unwrap(); + + assert!(result, "cursor present → should continue pagination"); + assert_eq!(captured.len(), 1); + assert_eq!( + captured[0], + json!([{"id": 1}, {"id": 2}]), + "captured per-page value must be the extracted subvalue", + ); + match page_state { + PageState::Cursor(Some(ref t)) => assert_eq!(t, "page-2"), + other => panic!("expected Cursor(Some(\"page-2\")), got {other:?}"), + } + } + + #[tokio::test] + async fn test_handle_json_response_capture_output() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"items":["a"]}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(captured.len(), 1); + assert_eq!(pages_fetched, 1); + } + + #[tokio::test] + async fn test_handle_json_response_non_json_body() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + "not json at all", + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + false, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(pages_fetched, 0); + } + + #[tokio::test] + async fn test_handle_json_response_pagination_continues() { + let pagination = PaginationConfig { + page_all: true, + page_limit: 10, + page_delay_ms: 0, + ..PaginationConfig::default() + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"items":[],"nextPageToken":"next-tok"}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + false, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(result); + match page_state { + PageState::Cursor(Some(ref t)) => assert_eq!(t, "next-tok"), + other => panic!("expected Cursor(Some(\"next-tok\")), got {other:?}"), + } + } + + #[tokio::test] + async fn test_handle_json_response_pagination_at_limit() { + let pagination = PaginationConfig { + page_all: true, + page_limit: 5, + page_delay_ms: 0, + ..PaginationConfig::default() + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 4u32; // becomes 5 == page_limit, no continuation + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"items":[],"nextPageToken":"would-be-next"}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + false, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(pages_fetched, 5); + } + + // --------------------------------------------------------------- + // Per-operation x-fern-pagination: cursor + offset coverage + // --------------------------------------------------------------- + + fn page_all_pagination() -> PaginationConfig { + PaginationConfig { + page_all: true, + page_limit: 10, + page_delay_ms: 0, + ..PaginationConfig::default() + } + } + + #[tokio::test] + async fn test_per_op_cursor_pagination_continues_with_response_path() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Cursor { + cursor: "marker".to_string(), + next_cursor: "next_marker".to_string(), + results: "entries".to_string(), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"entries":[{"id":"1"}],"next_marker":"abc"}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(result); + match page_state { + PageState::Cursor(Some(ref t)) => assert_eq!(t, "abc"), + other => panic!("expected Cursor(Some(\"abc\")), got {other:?}"), + } + } + + #[tokio::test] + async fn test_per_op_cursor_stops_on_empty_next_cursor() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Cursor { + cursor: "marker".to_string(), + next_cursor: "next_marker".to_string(), + results: "entries".to_string(), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"entries":[{"id":"2"}],"next_marker":""}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(!result); + } + + #[tokio::test] + async fn test_per_op_offset_pagination_advances_by_results_len() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: Some("meta.has_more".to_string()), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"users":[{"id":1},{"id":2},{"id":3}],"meta":{"has_more":true}}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(result); + match page_state { + PageState::Offset(n) => assert_eq!(n, 3), + other => panic!("expected Offset(3), got {other:?}"), + } + } + + #[tokio::test] + async fn test_per_op_offset_stops_when_has_next_page_false() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: Some("meta.has_more".to_string()), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"users":[{"id":1}],"meta":{"has_more":false}}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(!result); + } + + #[tokio::test] + async fn test_per_op_offset_step_stops_on_short_page() { + // `step: $request.limit` + caller's `--limit 50` → the executor + // gates the next page on `items.length >= 50`. The server returned + // only 3 rows (a short page), so pagination must stop even though + // `has_next_page` is unset. Matches upstream fern's hasNextPage + // check `items.length >= step`. + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "offset".to_string(), + results: "users".to_string(), + step: Some("limit".to_string()), + has_next_page: None, + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + let request_query_params = vec![("limit".to_string(), "50".to_string())]; + + let result = handle_json_response( + r#"{"users":[{"id":1},{"id":2},{"id":3}]}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &request_query_params, + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!( + !result, + "short page (3 < 50) must end pagination per upstream `items.length >= step` gate" + ); + } + + #[tokio::test] + async fn test_per_op_offset_step_continues_on_full_page() { + // Full page: server returned `limit` items → continue and advance + // by `len(items)` (item-index semantics). + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "offset".to_string(), + results: "users".to_string(), + step: Some("limit".to_string()), + has_next_page: None, + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + let request_query_params = vec![("limit".to_string(), "3".to_string())]; + + let result = handle_json_response( + r#"{"users":[{"id":1},{"id":2},{"id":3}]}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &request_query_params, + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(result, "full page (3 >= 3) must continue pagination"); + match page_state { + PageState::Offset(n) => assert_eq!( + n, 3, + "offset advances by len(items), not by the step value" + ), + other => panic!("expected Offset(3), got {other:?}"), + } + } + + #[test] + fn test_resolve_step_target_from_request_param() { + // step: "limit" + query params containing limit=50 → Some(50). + let params = vec![("limit".to_string(), "50".to_string())]; + assert_eq!(resolve_step_target(Some("limit"), ¶ms), Some(50)); + } + + #[test] + fn test_resolve_step_target_literal_integer() { + // step is itself an integer literal (e.g. `step: "50"`) → Some(50). + assert_eq!(resolve_step_target(Some("50"), &[]), Some(50)); + } + + #[test] + fn test_resolve_step_target_unresolvable_returns_none() { + // step references a param the caller didn't supply → None, so the + // executor falls back to the legacy `items.len() > 0` check. + assert_eq!(resolve_step_target(Some("limit"), &[]), None); + } + + #[test] + fn test_resolve_step_target_none_returns_none() { + assert_eq!(resolve_step_target(None, &[]), None); + } + + #[test] + fn test_page_state_injection_heuristic_first_page() { + let state = PageState::Cursor(None); + assert_eq!(state.injection(None, "pageToken"), None); + } + + #[test] + fn test_page_state_injection_heuristic_with_token() { + let state = PageState::Cursor(Some("tok".to_string())); + assert_eq!( + state.injection(None, "pageToken"), + Some(("pageToken".to_string(), "tok".to_string())), + ); + } + + #[test] + fn test_page_state_injection_endpoint_cursor_uses_op_param_name() { + let endpoint = EndpointPagination::Cursor { + cursor: "marker".to_string(), + next_cursor: "next_marker".to_string(), + results: "entries".to_string(), + }; + let state = PageState::Cursor(Some("tok".to_string())); + assert_eq!( + state.injection(Some(&endpoint), "pageToken"), + Some(("marker".to_string(), "tok".to_string())), + ); + } + + #[test] + fn test_page_state_injection_offset_zero_skipped_on_first_page() { + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: None, + }; + let state = PageState::Offset(0); + assert_eq!(state.injection(Some(&endpoint), "pageToken"), None); + } + + #[test] + fn test_page_state_injection_offset_nonzero_injects() { + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: None, + }; + let state = PageState::Offset(42); + assert_eq!( + state.injection(Some(&endpoint), "pageToken"), + Some(("page_number".to_string(), "42".to_string())), + ); + } + + #[test] + fn test_mime_from_extension_various() { + assert_eq!(mime_from_extension("doc.txt"), Some("text/plain".to_string())); + assert_eq!(mime_from_extension("page.htm"), Some("text/html".to_string())); + assert_eq!(mime_from_extension("style.css"), Some("text/css".to_string())); + assert_eq!(mime_from_extension("data.xml"), Some("application/xml".to_string())); + assert_eq!(mime_from_extension("app.js"), Some("application/javascript".to_string())); + assert_eq!(mime_from_extension("doc.pdf"), Some("application/pdf".to_string())); + assert_eq!(mime_from_extension("arc.zip"), Some("application/zip".to_string())); + assert_eq!(mime_from_extension("file.gz"), Some("application/gzip".to_string())); + assert_eq!(mime_from_extension("file.gzip"), Some("application/gzip".to_string())); + assert_eq!(mime_from_extension("archive.tar"), Some("application/x-tar".to_string())); + assert_eq!(mime_from_extension("img.png"), Some("image/png".to_string())); + assert_eq!(mime_from_extension("photo.jpg"), Some("image/jpeg".to_string())); + assert_eq!(mime_from_extension("photo.jpeg"), Some("image/jpeg".to_string())); + assert_eq!(mime_from_extension("anim.gif"), Some("image/gif".to_string())); + assert_eq!(mime_from_extension("icon.svg"), Some("image/svg+xml".to_string())); + assert_eq!(mime_from_extension("img.webp"), Some("image/webp".to_string())); + assert_eq!(mime_from_extension("fav.ico"), Some("image/x-icon".to_string())); + assert_eq!(mime_from_extension("song.mp3"), Some("audio/mpeg".to_string())); + assert_eq!(mime_from_extension("sound.wav"), Some("audio/wav".to_string())); + assert_eq!(mime_from_extension("video.mp4"), Some("video/mp4".to_string())); + assert_eq!(mime_from_extension("clip.webm"), Some("video/webm".to_string())); + assert_eq!(mime_from_extension("config.yaml"), Some("application/yaml".to_string())); + assert_eq!(mime_from_extension("config.yml"), Some("application/yaml".to_string())); + assert_eq!(mime_from_extension("config.toml"), Some("application/toml".to_string())); + assert_eq!(mime_from_extension("word.doc"), Some("application/msword".to_string())); + assert_eq!(mime_from_extension("word.docx"), Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document".to_string())); + assert_eq!(mime_from_extension("sheet.xls"), Some("application/vnd.ms-excel".to_string())); + assert_eq!(mime_from_extension("sheet.xlsx"), Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".to_string())); + assert_eq!(mime_from_extension("slides.ppt"), Some("application/vnd.ms-powerpoint".to_string())); + assert_eq!(mime_from_extension("slides.pptx"), Some("application/vnd.openxmlformats-officedocument.presentationml.presentation".to_string())); + assert_eq!(mime_from_extension("module.wasm"), Some("application/wasm".to_string())); + assert_eq!(mime_from_extension("file.unknown"), None); + } + + #[test] + fn test_mime_to_extension_additional_branches() { + assert_eq!(mime_to_extension("image/gif"), "gif"); + // Use MIMEs that don't contain "xml" (which matches earlier in the chain) + assert_eq!(mime_to_extension("application/vnd.ms-excel.spreadsheet"), "xlsx"); + assert_eq!(mime_to_extension("application/vnd.ms-word.document.12"), "docx"); + assert_eq!(mime_to_extension("application/octet-stream"), "bin"); + assert_eq!(mime_to_extension("application/unknown-type"), "bin"); + } + + #[test] + fn test_resolve_upload_mime_strips_control_chars() { + let mime = resolve_upload_mime(Some("text/plain\rinjected"), None, &None); + assert_eq!(mime, "text/plaininjected"); + } + + #[test] + fn test_resolve_upload_mime_all_control_chars_falls_back() { + let mime = resolve_upload_mime(Some("\r\n\t"), None, &None); + assert_eq!(mime, "application/octet-stream"); + } + + #[test] + fn test_value_to_query_string_null() { + assert_eq!(value_to_query_string(&Value::Null), ""); + } + + #[test] + fn test_value_to_query_string_object_serializes() { + let val = json!({"key": "val"}); + let result = value_to_query_string(&val); + assert!(!result.is_empty()); + } + + #[test] + fn test_serialize_deep_object_non_object_value() { + let result = serialize_deep_object("filter", &json!("simple")); + assert_eq!(result, vec![("filter".to_string(), "simple".to_string())]); + } + + #[test] + fn test_serialize_deep_object_nested() { + // Multi-level nesting: {"meta":{"created_at":"today"}} with key "filter" + // should produce [("filter[meta][created_at]", "today")] + let value = json!({"meta": {"created_at": "today"}}); + let result = serialize_deep_object("filter", &value); + assert_eq!( + result, + vec![("filter[meta][created_at]".to_string(), "today".to_string())] + ); + } + + #[test] + fn test_serialize_deep_object_array_uses_repeated_keys() { + // Arrays must use repeated keys (filter[tags]=a&filter[tags]=b), + // consistent with the Fern Python and C# SDKs. Not indexed brackets. + let value = json!({"tags": ["a", "b"]}); + let mut result = serialize_deep_object("filter", &value); + result.sort(); // order not guaranteed + assert_eq!( + result, + vec![ + ("filter[tags]".to_string(), "a".to_string()), + ("filter[tags]".to_string(), "b".to_string()), + ] + ); + } + + #[test] + fn test_build_url_uses_root_url_and_service_path_when_no_base_url() { + let doc = RestDescription { + root_url: "https://api.example.com/".to_string(), + service_path: "v1/".to_string(), + base_url: None, + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/files"); + } + + #[test] + fn test_build_url_method_root_url_overrides_doc_root_url() { + // Per-operation server override: method.root_url must win over doc.root_url. + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). + let doc = RestDescription { + root_url: "https://api.example.com/".to_string(), + service_path: "v1/".to_string(), + base_url: None, + ..Default::default() + }; + let method = RestMethod { + path: "uploads".to_string(), + root_url: "https://upload.example.com/".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://upload.example.com/v1/uploads"); + } + + #[test] + fn test_build_url_empty_method_root_url_falls_back_to_doc() { + // When method.root_url is empty (unset), doc.root_url must be used. + let doc = RestDescription { + root_url: "https://api.example.com/".to_string(), + service_path: "v1/".to_string(), + base_url: None, + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + root_url: String::new(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/files"); + } + + #[test] + fn test_parse_and_validate_inputs_invalid_params_json() { + let doc = RestDescription::default(); + let method = RestMethod::default(); + let err = + parse_and_validate_inputs(&doc, &method, Some("{not json}"), None, false, None, &[]).unwrap_err(); + assert!(err.to_string().contains("Invalid --params JSON")); + } + + #[test] + fn test_parse_and_validate_inputs_invalid_body_json() { + let doc = RestDescription::default(); + let method = RestMethod::default(); + let err = + parse_and_validate_inputs(&doc, &method, None, Some("{not json}"), false, None, &[]).unwrap_err(); + assert!(err.to_string().contains("Invalid --json body")); + } + + #[test] + fn test_parse_and_validate_inputs_required_query_param_missing() { + let mut parameters = HashMap::new(); + parameters.insert( + "api_key".to_string(), + MethodParameter { + location: Some("query".to_string()), + required: true, + ..Default::default() + }, + ); + let doc = RestDescription::default(); + let method = RestMethod { + parameters, + ..Default::default() + }; + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[]).unwrap_err(); + assert!(err.to_string().contains("Required parameter 'api_key'")); + } + + #[tokio::test] + async fn test_build_http_request_unsupported_method() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "TRACE".to_string(), + path: "test".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/test".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let err = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("Unsupported HTTP method")); + } + + #[tokio::test] + async fn test_build_http_request_put_patch_delete() { + let client = reqwest::Client::new(); + let input = ExecutionInput { + full_url: "https://example.com/test".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + for http_method in &["PUT", "PATCH", "DELETE"] { + let method = RestMethod { + http_method: http_method.to_string(), + path: "test".to_string(), + ..Default::default() + }; + let result = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await; + assert!(result.is_ok(), "Failed for method {http_method}"); + } + } + + #[test] + fn test_validate_value_schema_not_found() { + let doc = RestDescription::default(); + let mut errors = Vec::new(); + validate_value(&json!({}), "NonExistentSchema", &doc, "$", &mut errors); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("Schema 'NonExistentSchema' not found")); + } + + #[test] + fn test_resolve_next_path_absolute_url_overrides_base() { + // Server returned a fully-formed URL → use it verbatim. + let url = resolve_next_path( + "https://api.example.com/v1/things?cursor=a", + "https://other.example.com/v2/items?cursor=b", + ) + .unwrap(); + assert_eq!(url, "https://other.example.com/v2/items?cursor=b"); + } + + #[test] + fn test_resolve_next_path_absolute_path_keeps_scheme_and_host() { + // A `/`-prefixed path keeps the previous host but replaces the path. + let url = resolve_next_path( + "https://api.example.com/v1/things?cursor=a", + "/v1/things?cursor=b", + ) + .unwrap(); + assert_eq!(url, "https://api.example.com/v1/things?cursor=b"); + } + + #[test] + fn test_resolve_next_path_relative_path_inherits_directory() { + // No leading slash → resolved relative to the previous request's + // directory (browser-style URL resolution). + let url = resolve_next_path( + "https://api.example.com/v1/things", + "things?cursor=b", + ) + .unwrap(); + assert_eq!(url, "https://api.example.com/v1/things?cursor=b"); + } + + #[test] + fn test_resolve_next_path_rejects_invalid_base_url() { + let err = resolve_next_path("not a url", "/foo").unwrap_err(); + assert!(err.contains("not a valid URL"), "got: {err}"); + } + + #[test] + fn test_page_state_initial_for_each_form() { + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Cursor { + cursor: "c".into(), + next_cursor: "n".into(), + results: "r".into(), + })), + PageState::Cursor(None) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Offset { + offset: "o".into(), + results: "r".into(), + step: None, + has_next_page: None, + })), + PageState::Offset(0) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Uri { + next_uri: "n".into(), + results: "r".into(), + })), + PageState::NextUrl(None) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Path { + next_path: "n".into(), + results: "r".into(), + })), + PageState::NextUrl(None) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Custom { + results: "r".into(), + })), + PageState::Custom + )); + assert!(matches!(PageState::initial(None), PageState::Cursor(None))); + } + + #[test] + fn test_page_state_url_override_only_for_next_url() { + assert!(PageState::Cursor(None).url_override().is_none()); + assert!(PageState::Cursor(Some("tok".into())).url_override().is_none()); + assert!(PageState::Offset(5).url_override().is_none()); + assert!(PageState::NextUrl(None).url_override().is_none()); + assert!(PageState::Custom.url_override().is_none()); + let url = "https://api.example.com/v1/things?cursor=abc"; + assert_eq!( + PageState::NextUrl(Some(url.to_string())).url_override(), + Some(url) + ); + } + + #[test] + fn test_page_state_injection_uri_path_custom_no_query_param() { + // Uri/Path/Custom embed everything in the URL (or stop entirely); + // they must never push a cursor/offset query param. + let pagination = EndpointPagination::Uri { + next_uri: "next".into(), + results: "items".into(), + }; + assert!(PageState::NextUrl(Some("https://x".into())) + .injection(Some(&pagination), "page_token") + .is_none()); + let custom = EndpointPagination::Custom { + results: "items".into(), + }; + assert!(PageState::Custom.injection(Some(&custom), "page_token").is_none()); + } + + // ----------------------------------------------------------------- + // x-fern-streaming response decoding (`decode_stream_event`) + // + // The pure line decoder is the surface most likely to drift across + // server quirks (extra whitespace, comment lines, terminator + // variants), so we cover the full matrix here without touching the + // network. Wire-level integration is exercised in the tier-2 tests + // under tests/openapi_fixture_wire.rs. + // ----------------------------------------------------------------- + + // ----------------------------------------------------------------- + // SSE line decoding (`SseLineDecoder`) + // + // SSE is stateful — `data:` payloads are buffered across multiple + // lines and dispatched on a blank-line separator per the WHATWG + // spec. Tests below isolate the decoder so a regression in framing + // or multi-line concat points at the exact branch. + // ----------------------------------------------------------------- + + fn drive(lines: &[&str]) -> Vec { + let mut decoder = SseLineDecoder::default(); + let mut out = Vec::new(); + for line in lines { + if let Some(payload) = decoder.push_line(line) { + out.push(payload); + } + } + if let Some(payload) = decoder.flush() { + out.push(payload); + } + out + } + + #[test] + fn test_sse_decoder_strips_data_prefix_and_one_space() { + // `data: {"x":1}` decodes to `{"x":1}` — the single leading + // space after `data:` is consumed (matches the SSE spec). + let payloads = drive(&["data: {\"x\":1}", ""]); + assert_eq!(payloads, vec!["{\"x\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_no_space_after_data() { + // The space after `data:` is optional; the payload is + // preserved identically in both shapes. + let payloads = drive(&["data:{\"x\":1}", ""]); + assert_eq!(payloads, vec!["{\"x\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_skips_comments_and_unknown_fields() { + // Comments (`:`), `event:`, `id:`, `retry:`, and unknown + // fields are framing-only and must not pollute the dispatched + // payload. Only the `data:` line contributes to the event. + let payloads = drive(&[ + ": keepalive", + "event: message", + "id: 42", + "retry: 5000", + "data: {\"x\":1}", + "", + ]); + assert_eq!(payloads, vec!["{\"x\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_dispatches_on_blank_line_with_multiline_concat() { + // Three `data:` lines spanning a single pretty-printed JSON + // object — the WHATWG spec says they join with `\n` and + // dispatch as one event on the blank-line separator. The TS + // runtime's `iterSseEvents` loop does exactly this. + let payloads = drive(&[ + "data: {", + "data: \"foo\": 1", + "data: }", + "", + ]); + assert_eq!(payloads, vec!["{\n \"foo\": 1\n}".to_string()]); + } + + #[test] + fn test_sse_decoder_dispatches_two_events_separated_by_blank() { + let payloads = drive(&[ + "data: {\"step\":1}", + "", + "data: {\"step\":2}", + "", + ]); + assert_eq!( + payloads, + vec!["{\"step\":1}".to_string(), "{\"step\":2}".to_string(),] + ); + } + + #[test] + fn test_sse_decoder_flushes_final_event_without_blank_line() { + // EOF flush: when the server closes the connection without + // sending the trailing blank line, the buffered event must + // still be dispatched. Mirrors the TS post-loop + // `if (dataValue != null)` block. + let payloads = drive(&["data: {\"step\":1}"]); + assert_eq!(payloads, vec!["{\"step\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_blank_line_without_buffered_data_dispatches_nothing() { + // Resetting on blank without a buffered `data:` must not + // dispatch — an `event:` line followed by a blank line is + // discarded entirely. + let payloads = drive(&["event: ping", ""]); + assert!(payloads.is_empty(), "got unexpected events: {payloads:?}"); + } + + #[test] + fn test_decode_ndjson_emits_whole_line() { + let cfg = StreamingConfig::Json { terminator: None }; + assert_eq!( + decode_stream_event(&cfg, "{\"x\":1}"), + StreamEvent::Event("{\"x\":1}".to_string()) + ); + } + + #[test] + fn test_decode_ndjson_skips_blank_lines() { + // Some servers emit blank keepalive lines between records. + let cfg = StreamingConfig::Json { terminator: None }; + assert_eq!(decode_stream_event(&cfg, ""), StreamEvent::Skip); + } + + #[test] + fn test_decode_ndjson_terminator_only_when_configured() { + // Without a configured terminator, a literal `[DONE]` payload + // is just another event — NDJSON has no implicit sentinel. + let no_term = StreamingConfig::Json { terminator: None }; + assert_eq!( + decode_stream_event(&no_term, "[DONE]"), + StreamEvent::Event("[DONE]".to_string()) + ); + let with_term = StreamingConfig::Json { + terminator: Some("__END__".to_string()), + }; + assert_eq!( + decode_stream_event(&with_term, "__END__"), + StreamEvent::Terminate + ); + } + + #[test] + fn test_decode_text_emits_each_line_verbatim() { + // Plain-text format: no JSON parse, no SSE prefix strip, no + // terminator. Each non-empty line flows through as a string. + let cfg = StreamingConfig::Text; + assert_eq!( + decode_stream_event(&cfg, "hello world"), + StreamEvent::Event("hello world".to_string()) + ); + assert_eq!( + decode_stream_event(&cfg, "data: not stripped"), + StreamEvent::Event("data: not stripped".to_string()) + ); + assert_eq!( + decode_stream_event(&cfg, "{\"not\":\"parsed\"}"), + StreamEvent::Event("{\"not\":\"parsed\"}".to_string()) + ); + } + + #[test] + fn test_decode_text_skips_blank_lines() { + // Mirrors the C# generator's + // `if(!string.IsNullOrEmpty(line)) yield return line` guard. + let cfg = StreamingConfig::Text; + assert_eq!(decode_stream_event(&cfg, ""), StreamEvent::Skip); + } + + #[test] + fn test_project_text_event_bypasses_return_value_projection() { + // Text streams emit a raw line; `x-fern-sdk-return-value` + // and `--no-extract` are both no-ops because there's no + // JSON object to project against. + let cfg = StreamingConfig::Text; + let value = project_stream_event( + &cfg, + "raw line", + Some("$response.does.not.exist"), + false, + "test op", + ) + .expect("text projection must succeed"); + assert_eq!(value, Value::String("raw line".to_string())); + } +} + +#[tokio::test] +async fn test_execute_method_dry_run() { + let mut schemas = HashMap::new(); + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + crate::openapi::discovery::JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + schemas.insert( + "File".to_string(), + crate::openapi::discovery::JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + ); + + let doc = RestDescription { + root_url: "https://example.googleapis.com/".to_string(), + service_path: "v1/".to_string(), + schemas, + ..Default::default() + }; + + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + id: Some("example.files.create".to_string()), + path: "files/{fileId}".to_string(), + parameter_order: vec!["fileId".to_string()], + parameters, + request: Some(crate::openapi::discovery::SchemaRef { + schema_ref: Some("File".to_string()), + parameter_name: None, + }), + ..Default::default() + }; + + let params_json = r#"{"fileId": "123"}"#; + let body_json = r#"{"name": "test.txt"}"#; + + let pagination = PaginationConfig::default(); + + let http_config = crate::http::HttpConfig::new("test").unwrap(); + let result = execute_method( + &doc, + &method, + Some(params_json), + Some(body_json), + &crate::auth::no_auth_provider(), + None, + None, + None, + true, // dry_run + &pagination, + &crate::formatter::OutputPipeline::default(), + false, + None, + &http_config, + false, // no_extract + false, // no_retry + false, // no_stream + &[], + ) + .await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_execute_method_missing_path_param() { + // Same setup but missing required fileId in params + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let doc = RestDescription::default(); + let method = RestMethod { + http_method: "POST".to_string(), + path: "files/{fileId}".to_string(), + parameter_order: vec!["fileId".to_string()], + parameters, + ..Default::default() + }; + + let http_config = crate::http::HttpConfig::new("test").unwrap(); + let result = execute_method( + &doc, + &method, + None, // No params provided + None, + &crate::auth::no_auth_provider(), + None, + None, + None, + true, + &PaginationConfig::default(), + &crate::formatter::OutputPipeline::default(), + false, + None, + &http_config, + false, // no_extract + false, // no_retry + false, // no_stream + &[], + ) + .await; + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Required path parameter")); +} + +#[test] +fn test_get_value_type_helper() { + assert_eq!(get_value_type(&json!(null)), "null"); + assert_eq!(get_value_type(&json!(true)), "boolean"); + assert_eq!(get_value_type(&json!(42)), "integer"); + assert_eq!(get_value_type(&json!(3.5)), "number (float)"); + assert_eq!(get_value_type(&json!("string")), "string"); + assert_eq!(get_value_type(&json!([1, 2])), "array"); + assert_eq!(get_value_type(&json!({"a": 1})), "object"); +} + +#[tokio::test] +async fn test_post_without_body_sets_content_length_zero() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "POST".to_string(), + path: "messages/trash".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/messages/trash".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert_eq!( + built + .headers() + .get("Content-Length") + .map(|v| v.to_str().unwrap()), + Some("0"), + "POST with no body must include Content-Length: 0" + ); +} + +#[tokio::test] +async fn test_post_with_body_does_not_add_content_length_zero() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "POST".to_string(), + path: "files".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/files".to_string(), + body: Some(json!({"name": "test"})), + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + // When body is present, Content-Length should NOT be "0" + let cl = built + .headers() + .get("Content-Length") + .map(|v| v.to_str().unwrap().to_string()); + assert!(cl.is_none() || cl.as_deref() != Some("0")); +} + +#[tokio::test] +async fn test_get_does_not_set_content_length_zero() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "files".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/files".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert!( + built.headers().get("Content-Length").is_none(), + "GET with no body should not have Content-Length header" + ); +} + +// --------------------------------------------------------------------------- +// BearerHeader auth method +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_bearer_header_sends_bearer_prefix() { + use crate::openapi::discovery::RestMethod; + + let client = crate::http::HttpConfig::new("test").unwrap().build_client().unwrap(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "/test".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/test".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let provider: DynAuthProvider = std::sync::Arc::new(crate::auth::HeaderAuthProvider::new( + "scheme", + "X-Auth", + crate::auth::AuthCredentialSource::literal("mytoken"), + true, + )); + let request = build_http_request( + &client, + &method, + &input, + &provider, + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + let header_val = built.headers().get("x-auth").and_then(|v| v.to_str().ok()); + assert_eq!(header_val, Some("Bearer mytoken")); +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/help.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/help.rs new file mode 100644 index 000000000000..9e7c263ddbb2 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/help.rs @@ -0,0 +1,530 @@ +//! JSON help output — renders `--help --format json` as a machine-readable +//! schema. When an agent passes both `--help` (or `-h`) and `--format json`, +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. + +use serde_json::{json, Map, Value}; + +use crate::error::CliError; +use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; + +/// Renders JSON help for the given subcommand path and prints it to stdout. +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { + let output = match path.len() { + 0 => list_all_operations(doc), + 1 => list_resource_operations(doc, &path[0])?, + _ => { + // Try treating last element as a method name first. + // If that fails, the full path may resolve to a nested sub-resource — list its ops. + let resource_path: Vec<&str> = path[..path.len() - 1].iter().map(|s| s.as_str()).collect(); + let method_name = path[path.len() - 1].as_str(); + match operation_schema(doc, &resource_path, method_name) { + Ok(schema) => schema, + Err(_) => { + let full_path: Vec<&str> = path.iter().map(|s| s.as_str()).collect(); + list_nested_resource_operations(doc, &full_path)? + } + } + } + }; + + writeln!( + out, + "{}", + serde_json::to_string_pretty(&output) + .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? + ) + .map_err(|e| CliError::Other(e.into()))?; + Ok(()) +} + +fn list_all_operations(doc: &RestDescription) -> Value { + let mut ops: Vec = Vec::new(); + let mut names: Vec<_> = doc.resources.keys().collect(); + names.sort(); + for name in names { + collect_resource_ops(&doc.resources[name], &[name], &mut ops); + } + // Wrap the operations list in a top-level object that also exposes any + // `x-fern-sdk-variables` so a machine consumer can discover the global + // root flags (and their env-var fallbacks) without inspecting every + // operation. Falls back to the bare array when no variables are + // declared so existing consumers that expect a JSON array at the root + // are unaffected. + if doc.sdk_variables.is_empty() { + json!(ops) + } else { + json!({ + "sdkVariables": render_sdk_variables(&doc.sdk_variables), + "operations": ops, + }) + } +} + +fn render_sdk_variables( + vars: &[crate::openapi::discovery::SdkVariable], +) -> Vec { + vars.iter() + .map(|v| { + json!({ + "name": v.name, + "type": v.ty, + "description": v.description.as_deref().unwrap_or(""), + "globalFlag": format!("--{}", crate::text::to_kebab_flag(&v.name)), + "envVar": crate::text::to_screaming_snake(&v.name), + }) + }) + .collect() +} + +fn list_resource_operations(doc: &RestDescription, resource: &str) -> Result { + let res = doc + .resources + .get(resource) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {resource}")))?; + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, &[resource], &mut ops); + Ok(json!(ops)) +} + +fn list_nested_resource_operations(doc: &RestDescription, path: &[&str]) -> Result { + let first = path.first().ok_or_else(|| { + CliError::Validation("No resource specified".to_string()) + })?; + let mut res = doc + .resources + .get(*first) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {first}")))?; + for segment in &path[1..] { + res = res + .resources + .get(*segment) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {segment}")))?; + } + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, path, &mut ops); + Ok(json!(ops)) +} + +fn operation_schema(doc: &RestDescription, resource_path: &[&str], method_name: &str) -> Result { + let first = resource_path.first().ok_or_else(|| { + CliError::Validation("No resource specified".to_string()) + })?; + + let mut res = doc + .resources + .get(*first) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {first}")))?; + + for segment in &resource_path[1..] { + res = res + .resources + .get(*segment) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {segment}")))?; + } + + let method = res.methods.get(method_name).ok_or_else(|| { + CliError::Validation(format!( + "Operation not found: {} {method_name}", + resource_path.join(" ") + )) + })?; + + Ok(build_schema(resource_path, method_name, method)) +} + +fn build_schema(resource_path: &[&str], method_name: &str, method: &RestMethod) -> Value { + let mut properties: Map = Map::new(); + let mut required: Vec = Vec::new(); + + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for name in param_names { + let param = &method.parameters[name]; + let mut prop = json!({ + "type": param.param_type.as_deref().unwrap_or("string"), + "description": param.description.as_deref().unwrap_or(""), + "location": param.location.as_deref().unwrap_or("query"), + }); + if let Some(enums) = ¶m.enum_values { + prop["enum"] = json!(enums); + // When `x-fern-enum` overrides are present, expose the + // per-value display name and description so JSON-help + // consumers can render them without reparsing the spec. + if let Some(fern_enum) = ¶m.fern_enum { + let mut by_wire: Map = Map::new(); + for wire in enums { + if let Some(entry) = fern_enum.get(wire) { + let mut obj = Map::new(); + if let Some(name) = &entry.display_name { + obj.insert("name".to_string(), Value::String(name.clone())); + } + if let Some(desc) = &entry.description { + obj.insert("description".to_string(), Value::String(desc.clone())); + } + if !obj.is_empty() { + by_wire.insert(wire.clone(), Value::Object(obj)); + } + } + } + if !by_wire.is_empty() { + prop["x-fern-enum"] = Value::Object(by_wire); + } + } + } + if let Some(availability) = param.availability { + prop["availability"] = json!(availability.as_str()); + } + // Variable-bound path parameters are NOT per-op required flags; their + // value comes from the root-level global flag (kebab-cased) with an + // env-var fallback (SCREAMING_SNAKE_CASE), or from `--params` JSON. + // Mark them explicitly so machine consumers (LLM agents, code + // generators) know not to surface a per-op `--` flag and can + // discover the right global/env fallbacks instead. + if let Some(var_name) = param.variable_reference.as_deref() { + prop["binding"] = json!("sdk-variable"); + prop["variable"] = json!(var_name); + prop["globalFlag"] = json!(format!("--{}", crate::text::to_kebab_flag(var_name))); + prop["envVar"] = json!(crate::text::to_screaming_snake(var_name)); + } else if param.required { + required.push(name.clone()); + } + properties.insert(name.clone(), prop); + } + required.sort(); + + let mut output = json!({ + "operation": format!("{}.{}", resource_path.join("."), method_name), + "httpMethod": method.http_method, + "path": method.path, + "description": method.description.as_deref().unwrap_or(""), + "parameters": { + "type": "object", + "properties": properties, + "required": required, + }, + }); + if let Some(availability) = method.availability { + output["availability"] = json!(availability.as_str()); + } + output +} + +fn collect_resource_ops(res: &RestResource, path: &[&str], ops: &mut Vec) { + let mut method_names: Vec<_> = res.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let m = &res.methods[method_name]; + let mut entry = json!({ + "operation": format!("{}.{}", path.join("."), method_name), + "httpMethod": m.http_method, + "path": m.path, + "description": m.description.as_deref().unwrap_or(""), + }); + if let Some(availability) = m.availability { + entry["availability"] = json!(availability.as_str()); + } + ops.push(entry); + } + let mut sub_names: Vec<_> = res.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let mut sub_path = path.to_vec(); + sub_path.push(sub_name); + collect_resource_ops(&res.resources[sub_name], &sub_path, ops); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openapi::discovery::{MethodParameter, RestMethod, RestResource}; + use std::collections::HashMap; + + fn make_doc() -> RestDescription { + let mut params = HashMap::new(); + params.insert( + "user_id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user ID".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/users/{user_id}".to_string(), + description: Some("Get a user".to_string()), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_render_root_lists_all() { + let doc = make_doc(); + let output = list_all_operations(&doc); + let arr = output.as_array().unwrap(); + assert!(!arr.is_empty()); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_resource() { + let doc = make_doc(); + let output = list_resource_operations(&doc, "users").unwrap(); + let arr = output.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_operation_schema() { + let doc = make_doc(); + let schema = operation_schema(&doc, &["users"], "get").unwrap(); + assert_eq!(schema["httpMethod"], "GET"); + let required = schema["parameters"]["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v == "user_id")); + } + + #[test] + fn test_variable_bound_param_annotated_and_not_required_in_per_op_schema() { + // JSON help is the machine-readable contract for LLM agents. A + // variable-bound path parameter must NOT appear in the per-op + // `required` array (there is no per-op flag for it), and the + // property MUST carry enough metadata for an agent to resolve it + // via the root-level global flag, env var, or --params JSON. + let mut params = HashMap::new(); + params.insert( + "gardenId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Tenant id".to_string()), + location: Some("path".to_string()), + required: true, + variable_reference: Some("gardenId".to_string()), + ..Default::default() + }, + ); + // A plain (non-variable-bound) required path param on the same op + // MUST still show up in `required` as before. + params.insert( + "zoneId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Zone id".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "GET".to_string(), + path: "/gardens/{gardenId}/zones/{zoneId}".to_string(), + description: Some("List zones".to_string()), + parameters: params, + ..Default::default() + }; + let schema = build_schema(&["zones"], "get", &method); + let required = schema["parameters"]["required"].as_array().unwrap(); + assert!( + !required.iter().any(|v| v == "gardenId"), + "variable-bound param must not appear in per-op `required`, got: {required:?}", + ); + assert!( + required.iter().any(|v| v == "zoneId"), + "plain required path param must still be in `required`, got: {required:?}", + ); + + let garden = &schema["parameters"]["properties"]["gardenId"]; + assert_eq!(garden["binding"], "sdk-variable"); + assert_eq!(garden["variable"], "gardenId"); + assert_eq!(garden["globalFlag"], "--garden-id"); + assert_eq!(garden["envVar"], "GARDEN_ID"); + } + + #[test] + fn test_root_listing_surfaces_sdk_variables_when_declared() { + // With at least one `x-fern-sdk-variables` entry the root JSON + // help wraps the operations array in an object that exposes the + // variable definitions (name, type, description, derived flag, + // env var) so machine consumers can discover the root-level + // globals without scanning every operation. + let mut doc = make_doc(); + doc.sdk_variables = vec![crate::openapi::discovery::SdkVariable { + name: "gardenId".to_string(), + ty: "string".to_string(), + description: Some("Tenant id".to_string()), + }]; + let output = list_all_operations(&doc); + let obj = output.as_object().expect("expected wrapped object when sdk_variables present"); + let vars = obj["sdkVariables"].as_array().unwrap(); + assert_eq!(vars.len(), 1); + assert_eq!(vars[0]["name"], "gardenId"); + assert_eq!(vars[0]["globalFlag"], "--garden-id"); + assert_eq!(vars[0]["envVar"], "GARDEN_ID"); + assert_eq!(vars[0]["description"], "Tenant id"); + assert!( + obj["operations"].as_array().unwrap().iter().any(|op| op["operation"] == "users.get"), + "operations array must still list every op when wrapped", + ); + } + + #[test] + fn test_root_listing_stays_bare_array_when_no_sdk_variables() { + // Backwards-compat: specs without any `x-fern-sdk-variables` + // still produce a top-level JSON array (the shape every + // existing consumer expects). + let doc = make_doc(); + let output = list_all_operations(&doc); + assert!( + output.is_array(), + "root JSON help must stay a bare array when no sdk_variables are declared", + ); + } + + #[test] + fn test_render_json_help_nested_sub_resource_listing() { + // path.len() == 2 where last element is a sub-resource, not a method + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::openapi::discovery::RestMethod { + http_method: "GET".to_string(), + path: "/organizations/{id}/memberships/{mid}".to_string(), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + RestResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + RestResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into()]; + let result = render_json_help(&doc, &path); + assert!(result.is_ok(), "sub-resource path should list operations, not error"); + } + + #[test] + fn test_render_nested_operation_schema() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::openapi::discovery::RestMethod { + http_method: "GET".to_string(), + path: "/organizations/{org_id}/memberships/{membership_id}".to_string(), + description: Some("Get a membership".to_string()), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + RestResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + RestResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let schema = operation_schema(&doc, &["organizations", "memberships"], "get-membership").unwrap(); + assert_eq!(schema["operation"], "organizations.memberships.get-membership"); + assert_eq!(schema["httpMethod"], "GET"); + } + + #[test] + fn test_render_json_help_dispatches_nested_path() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::openapi::discovery::RestMethod { + http_method: "GET".to_string(), + path: "/orgs/{id}/memberships/{mid}".to_string(), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + RestResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + RestResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into(), "get-membership".into()]; + // Should not error — previously would pass "memberships" as method name + let result = render_json_help(&doc, &path); + assert!(result.is_ok(), "nested path should resolve correctly"); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/mod.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/mod.rs new file mode 100644 index 000000000000..cdc657e97ca8 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/mod.rs @@ -0,0 +1,15 @@ +mod app; +mod binding; +pub mod commands; +mod help; +pub mod executor; +pub mod overlay; +mod parser; +pub mod discovery; +pub mod skill_emitter; + +pub use self::app::{AppContext, resolve_method_from_matches}; +pub(crate) use self::app::CliApp; +pub use self::binding::OpenApiBinding; +pub use self::overlay::{apply_overlay, apply_overlays_to_spec, parse_overlay, validate_overlay}; +pub use self::parser::{deep_merge_yaml, load_openapi_spec, load_openapi_spec_from_value}; diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/overlay.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/overlay.rs new file mode 100644 index 000000000000..cfea79a0e77f --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/overlay.rs @@ -0,0 +1,1829 @@ +//! OpenAPI Overlay support (v1.0.0) +//! +//! Applies [OpenAPI Overlays](https://spec.openapis.org/overlay/latest.html) to +//! an OpenAPI document represented as a generic JSON value. Each overlay contains +//! a list of *actions* whose `target` is a JSONPath (RFC 9535) expression. Actions +//! either **update** (deep-merge) or **remove** matched nodes. + +use serde::Deserialize; +use serde_json::Value; +use serde_json_path::JsonPath; + +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// Overlay document types +// --------------------------------------------------------------------------- + +/// A single overlay action targeting nodes via a JSONPath expression. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayAction { + /// JSONPath (RFC 9535) expression selecting target nodes. + pub target: String, + /// Human-readable description of the action. + #[serde(default)] + pub description: Option, + /// Value to deep-merge into each matched node. Required when `remove` is + /// false/absent. + #[serde(default)] + pub update: Option, + /// When `true`, matched nodes are removed instead of updated. + #[serde(default)] + pub remove: bool, +} + +/// Metadata block inside an overlay document. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayInfo { + pub title: String, + pub version: String, +} + +/// A complete overlay document. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayDocument { + /// Overlay specification version (e.g. `"1.0.0"`). + pub overlay: String, + /// Metadata about this overlay. + pub info: OverlayInfo, + /// Optional base document this overlay extends. + #[serde(default)] + pub extends: Option, + /// Ordered list of actions to apply. + pub actions: Vec, +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Parse an overlay document from a YAML or JSON string. +pub fn parse_overlay(input: &str) -> Result { + // Try JSON first, then YAML + serde_json::from_str::(input) + .or_else(|_| { + let yaml_value: serde_yaml::Value = serde_yaml::from_str(input) + .map_err(|e| CliError::Discovery(format!("Failed to parse overlay file: {e}")))?; + let json_value = yaml_to_json(yaml_value); + serde_json::from_value::(json_value) + .map_err(|e| CliError::Discovery(format!("Failed to parse overlay file: {e}"))) + }) + .map_err(|e| match e { + CliError::Discovery(_) => e, + _ => CliError::Discovery(format!("Failed to parse overlay file: {e}")), + }) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validate the structure of a parsed overlay document. +pub fn validate_overlay(overlay: &OverlayDocument) -> Result<(), CliError> { + if overlay.overlay.is_empty() { + return Err(CliError::Validation( + "Overlay file missing required 'overlay' version field".to_string(), + )); + } + + if overlay.info.title.is_empty() || overlay.info.version.is_empty() { + return Err(CliError::Validation( + "Overlay file missing required 'info.title' or 'info.version' field".to_string(), + )); + } + + if overlay.actions.is_empty() { + return Err(CliError::Validation( + "Overlay file must have at least one action".to_string(), + )); + } + + for (i, action) in overlay.actions.iter().enumerate() { + if action.target.is_empty() { + return Err(CliError::Validation(format!( + "Overlay action at index {i} missing required 'target' field" + ))); + } + if action.update.is_none() && !action.remove { + return Err(CliError::Validation(format!( + "Overlay action at index {i} must have either 'update' or 'remove'" + ))); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Application +// --------------------------------------------------------------------------- + +/// Apply an overlay document to an OpenAPI spec represented as a JSON value. +/// +/// Actions are applied sequentially; each one operates on the result of the +/// previous action. This function does **not** mutate the input — it returns a +/// new value. +pub fn apply_overlay(doc: &Value, overlay: &OverlayDocument) -> Result { + let mut output = doc.clone(); + + for (i, action) in overlay.actions.iter().enumerate() { + let path = JsonPath::parse(&action.target).map_err(|e| { + CliError::Validation(format!( + "Invalid JSONPath in overlay action {i} (target: '{}'): {e}", + action.target + )) + })?; + + if action.remove { + apply_remove(&mut output, &path); + } else if let Some(ref update) = action.update { + apply_update(&mut output, &path, update)?; + } + } + + Ok(output) +} + +/// Apply a remove action: delete all nodes matched by `path`. +fn apply_remove(doc: &mut Value, path: &JsonPath) { + let located = path.query_located(doc); + // Collect normalized paths; process in reverse so array indices stay valid + let mut paths: Vec> = located + .iter() + .map(|node| normalized_path_to_segments(node.location())) + .collect(); + paths.sort_by(|a, b| b.cmp(a)); + + for segments in &paths { + remove_at_path(doc, segments); + } +} + +/// Apply an update (deep-merge) action to all nodes matched by `path`. +fn apply_update(doc: &mut Value, path: &JsonPath, update: &Value) -> Result<(), CliError> { + let located = path.query_located(doc); + let paths: Vec> = located + .iter() + .map(|node| normalized_path_to_segments(node.location())) + .collect(); + + if paths.is_empty() { + return Ok(()); + } + + for segments in &paths { + if segments.is_empty() { + // Root target — merge directly into doc + if let Value::Object(_) = update { + deep_merge(doc, update); + } + } else { + merge_at_path(doc, segments, update); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Path navigation helpers +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum PathSegment { + Key(String), + Index(usize), +} + +/// Convert a `serde_json_path` `NormalizedPath` location into our own segment list. +fn normalized_path_to_segments( + location: &serde_json_path::NormalizedPath<'_>, +) -> Vec { + location + .iter() + .filter_map(|elem| { + if let Some(name) = elem.as_name() { + Some(PathSegment::Key(name.to_string())) + } else { + elem.as_index().map(PathSegment::Index) + } + }) + .collect() +} + + +/// Navigate to a path's parent and remove the target node. +fn remove_at_path(doc: &mut Value, segments: &[PathSegment]) { + if segments.is_empty() { + return; + } + + let (parent_segments, last) = segments.split_at(segments.len() - 1); + let last = &last[0]; + + let parent = navigate_to_mut(doc, parent_segments); + let Some(parent) = parent else { return }; + + match last { + PathSegment::Key(key) => { + if let Value::Object(map) = parent { + map.remove(key); + } + } + PathSegment::Index(idx) => { + if let Value::Array(arr) = parent { + if *idx < arr.len() { + arr.remove(*idx); + } + } + } + } +} + +/// Navigate to a path and deep-merge the update value. +fn merge_at_path(doc: &mut Value, segments: &[PathSegment], update: &Value) { + let target = navigate_to_mut(doc, segments); + let Some(target) = target else { return }; + + // Match Fern CLI behavior (applyOpenAPIOverlay.ts L74-77): when the target + // is an array and the update is NOT itself an array, append the value. + if let Value::Array(arr) = target { + if !update.is_array() { + arr.push(update.clone()); + return; + } + } + + deep_merge(target, update); +} + +/// Walk the JSON tree following the given segments, returning a mutable ref to +/// the target node, or `None` if the path does not exist. +fn navigate_to_mut<'a>(doc: &'a mut Value, segments: &[PathSegment]) -> Option<&'a mut Value> { + let mut current = doc; + for segment in segments { + current = match segment { + PathSegment::Key(key) => current.get_mut(key.as_str())?, + PathSegment::Index(idx) => current.get_mut(*idx)?, + }; + } + Some(current) +} + +// --------------------------------------------------------------------------- +// Deep merge +// --------------------------------------------------------------------------- + +/// Recursively merge `update` into `base`, matching lodash `merge` semantics. +/// +/// - Objects are merged key-by-key (recursive). +/// - Arrays are merged index-by-index: each element in `update` is deep-merged +/// into the corresponding index of `base`. If `update` is shorter, trailing +/// `base` elements are preserved. If `update` is longer, new elements are +/// appended. +/// - All other types are overwritten. +pub fn deep_merge(base: &mut Value, update: &Value) { + match (base, update) { + (Value::Object(base_map), Value::Object(update_map)) => { + for (key, update_val) in update_map { + let entry = base_map + .entry(key.clone()) + .or_insert(Value::Null); + deep_merge(entry, update_val); + } + } + (Value::Array(base_arr), Value::Array(update_arr)) => { + for (i, update_val) in update_arr.iter().enumerate() { + if i < base_arr.len() { + deep_merge(&mut base_arr[i], update_val); + } else { + base_arr.push(update_val.clone()); + } + } + } + (base, update) => { + *base = update.clone(); + } + } +} + +// --------------------------------------------------------------------------- +// YAML → JSON conversion +// --------------------------------------------------------------------------- + +/// Convert a `serde_yaml::Value` into a `serde_json::Value`. +fn yaml_to_json(yaml: serde_yaml::Value) -> Value { + match yaml { + serde_yaml::Value::Null => Value::Null, + serde_yaml::Value::Bool(b) => Value::Bool(b), + serde_yaml::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Number(i.into()) + } else if let Some(u) = n.as_u64() { + Value::Number(u.into()) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null) + } else { + Value::Null + } + } + serde_yaml::Value::String(s) => Value::String(s), + serde_yaml::Value::Sequence(seq) => { + Value::Array(seq.into_iter().map(yaml_to_json).collect()) + } + serde_yaml::Value::Mapping(map) => { + let obj = map + .into_iter() + .filter_map(|(k, v)| { + let key = match k { + serde_yaml::Value::String(s) => s, + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + _ => return None, + }; + Some((key, yaml_to_json(v))) + }) + .collect(); + Value::Object(obj) + } + serde_yaml::Value::Tagged(tagged) => yaml_to_json(tagged.value), + } +} + +/// Parse an OpenAPI spec string (YAML or JSON) into a `serde_json::Value`, +/// apply a list of overlay strings, and return the modified JSON value +/// serialised back to a YAML string suitable for `load_openapi_spec`. +pub fn apply_overlays_to_spec( + spec_yaml: &str, + overlay_strings: &[String], +) -> Result { + if overlay_strings.is_empty() { + return Ok(spec_yaml.to_string()); + } + + // Parse spec into a generic JSON value + let yaml_value: serde_yaml::Value = serde_yaml::from_str(spec_yaml) + .map_err(|e| CliError::Discovery(format!("Failed to parse OpenAPI spec: {e}")))?; + let mut doc = yaml_to_json(yaml_value); + + for (idx, overlay_str) in overlay_strings.iter().enumerate() { + let overlay = parse_overlay(overlay_str).map_err(|e| { + CliError::Discovery(format!("Failed to parse overlay {idx}: {e}")) + })?; + validate_overlay(&overlay).map_err(|e| { + CliError::Validation(format!("Invalid overlay {idx}: {e}")) + })?; + + tracing::debug!( + "Applying overlay \"{}\" v{}", + overlay.info.title, + overlay.info.version + ); + + doc = apply_overlay(&doc, &overlay)?; + } + + // Serialize back to YAML + serde_yaml::to_string(&doc) + .map_err(|e| CliError::Discovery(format!("Failed to serialize overlaid spec: {e}"))) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // -- deep_merge -- + + #[test] + fn test_deep_merge_objects() { + let mut base = json!({"a": 1, "b": {"c": 2}}); + let update = json!({"b": {"d": 3}, "e": 4}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 1, "b": {"c": 2, "d": 3}, "e": 4})); + } + + #[test] + fn test_deep_merge_overwrites_primitives() { + let mut base = json!({"a": 1}); + let update = json!({"a": 2}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 2})); + } + + #[test] + fn test_deep_merge_nested() { + let mut base = json!({"a": {"b": {"c": 1, "d": 2}}}); + let update = json!({"a": {"b": {"c": 10, "e": 3}}}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": {"b": {"c": 10, "d": 2, "e": 3}}})); + } + + // -- parse_overlay -- + + #[test] + fn test_parse_overlay_yaml() { + let yaml = r#" +overlay: "1.0.0" +info: + title: Test Overlay + version: "1.0" +actions: + - target: "$.info" + update: + description: "Updated description" +"#; + let doc = parse_overlay(yaml).unwrap(); + assert_eq!(doc.overlay, "1.0.0"); + assert_eq!(doc.info.title, "Test Overlay"); + assert_eq!(doc.actions.len(), 1); + } + + #[test] + fn test_parse_overlay_json() { + let json_str = r#"{ + "overlay": "1.0.0", + "info": {"title": "Test", "version": "1.0"}, + "actions": [ + {"target": "$.info", "update": {"description": "hi"}} + ] + }"#; + let doc = parse_overlay(json_str).unwrap(); + assert_eq!(doc.overlay, "1.0.0"); + assert_eq!(doc.actions.len(), 1); + } + + // -- validate_overlay -- + + #[test] + fn test_validate_overlay_missing_version() { + let doc = OverlayDocument { + overlay: String::new(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: Some(json!({})), + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_no_actions() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_action_no_target() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: String::new(), + description: None, + update: Some(json!({})), + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_action_no_update_no_remove() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: None, + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + // -- apply_overlay: update -- + + #[test] + fn test_overlay_update_simple_path() { + let doc = json!({ + "info": {"title": "Old", "version": "1.0"}, + "paths": {} + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: Some(json!({"title": "New", "description": "Added"})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["info"]["title"], "New"); + assert_eq!(result["info"]["version"], "1.0"); + assert_eq!(result["info"]["description"], "Added"); + } + + #[test] + fn test_overlay_update_nested_path() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.User".into(), + description: None, + update: Some(json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + })), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["User"]["properties"]["email"].is_object()); + } + + // -- apply_overlay: remove -- + + #[test] + fn test_overlay_remove_property() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.User.properties.email".into(), + description: None, + update: None, + remove: true, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["User"]["properties"]["email"].is_null()); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["name"]["type"], + "string" + ); + } + + // -- apply_overlay: wildcard -- + + #[test] + fn test_overlay_wildcard_update() { + let doc = json!({ + "paths": { + "/users": { + "get": {"summary": "Get users"} + }, + "/posts": { + "get": {"summary": "Get posts"} + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.paths.*.get".into(), + description: None, + update: Some(json!({"security": [{"Bearer": []}]})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["paths"]["/users"]["get"]["security"].is_array()); + assert!(result["paths"]["/posts"]["get"]["security"].is_array()); + } + + // -- apply_overlay: zero matches -- + + #[test] + fn test_overlay_zero_match_no_error() { + let doc = json!({"info": {"title": "Test"}}); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.nonexistent.path".into(), + description: None, + update: Some(json!({"x": 1})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result, doc); + } + + // -- apply_overlay: sequential actions -- + + #[test] + fn test_overlay_sequential_actions() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![ + OverlayAction { + target: "$.components.schemas.User".into(), + description: None, + update: Some(json!({ + "properties": { + "id": {"type": "string"}, + "profile": {"type": "object", "properties": {"name": {"type": "string"}}} + } + })), + remove: false, + }, + OverlayAction { + target: "$.components.schemas.User.properties.profile".into(), + description: None, + update: Some(json!({ + "properties": { + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"} + } + })), + remove: false, + }, + ], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["profile"]["properties"]["email"]["type"], + "string" + ); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["profile"]["properties"]["name"]["type"], + "string" + ); + } + + // -- apply_overlay: root target -- + + #[test] + fn test_overlay_root_target() { + let doc = json!({"info": {"title": "Old"}}); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$".into(), + description: None, + update: Some(json!({"info": {"title": "New", "version": "2.0"}})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["info"]["title"], "New"); + assert_eq!(result["info"]["version"], "2.0"); + } + + // -- apply_overlays_to_spec -- + + #[test] + fn test_apply_overlays_to_spec_roundtrip() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: Add description + version: "1.0" +actions: + - target: "$.info" + update: + description: "A plant management API" +"#; + + let result = apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); + // The result should be valid YAML that can be parsed + let parsed: serde_yaml::Value = serde_yaml::from_str(&result).unwrap(); + let info = &parsed["info"]; + assert_eq!(info["description"], serde_yaml::Value::String("A plant management API".into())); + // Original fields preserved + assert_eq!(info["title"], serde_yaml::Value::String("Test API".into())); + } + + #[test] + fn test_apply_overlays_to_spec_no_overlays() { + let spec = "openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\n"; + let result = apply_overlays_to_spec(spec, &[]).unwrap(); + assert_eq!(result, spec); + } + + // -- array removal -- + + #[test] + fn test_overlay_remove_array_element() { + let doc = json!({ + "paths": { + "/plants": { + "get": { + "parameters": [ + {"name": "id", "in": "query"}, + {"name": "limit", "in": "query"}, + {"name": "offset", "in": "query"} + ] + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.paths['/plants'].get.parameters[1]".into(), + description: None, + update: None, + remove: true, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = result["paths"]["/plants"]["get"]["parameters"].as_array().unwrap(); + assert_eq!(params.len(), 2); + assert_eq!(params[0]["name"], "id"); + assert_eq!(params[1]["name"], "offset"); + } + + // -- multiple overlays -- + + #[test] + fn test_apply_multiple_overlays() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +paths: {} +"#; + let overlay1 = r#" +overlay: "1.0.0" +info: + title: Overlay 1 + version: "1.0" +actions: + - target: "$.info" + update: + description: "First overlay" +"#; + let overlay2 = r#" +overlay: "1.0.0" +info: + title: Overlay 2 + version: "1.0" +actions: + - target: "$.info" + update: + contact: + name: "Plant Store Support" +"#; + let result = apply_overlays_to_spec(spec, &[overlay1.to_string(), overlay2.to_string()]).unwrap(); + let parsed: serde_yaml::Value = serde_yaml::from_str(&result).unwrap(); + assert_eq!( + parsed["info"]["description"], + serde_yaml::Value::String("First overlay".into()) + ); + assert_eq!( + parsed["info"]["contact"]["name"], + serde_yaml::Value::String("Plant Store Support".into()) + ); + } + + // -- deep merge preserves existing keys -- + + #[test] + fn test_deep_merge_preserves_existing() { + let doc = json!({ + "components": { + "schemas": { + "Plant": { + "type": "object", + "properties": { + "species": {"type": "string"}, + "height": {"type": "number"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.Plant.properties".into(), + description: None, + update: Some(json!({ + "species": {"type": "string", "description": "The plant species"}, + "color": {"type": "string"} + })), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["components"]["schemas"]["Plant"]["properties"]["height"]["type"], "number"); + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["species"]["description"], + "The plant species" + ); + assert_eq!(result["components"]["schemas"]["Plant"]["properties"]["color"]["type"], "string"); + } + + // ----------------------------------------------------------------------- + // Tests ported from Fern CLI TypeScript (applyOpenAPIOverlay.test.ts) + // These ensure behavioral parity with the Fern CLI overlay implementation. + // ----------------------------------------------------------------------- + + fn make_overlay(actions: Vec) -> OverlayDocument { + OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "Test".into(), version: "1.0".into() }, + extends: None, + actions, + } + } + + fn update_action(target: &str, update: Value) -> OverlayAction { + OverlayAction { + target: target.into(), + description: None, + update: Some(update), + remove: false, + } + } + + fn remove_action(target: &str) -> OverlayAction { + OverlayAction { + target: target.into(), + description: None, + update: None, + remove: true, + } + } + + /// Port of TS: "should merge updates into a schema at a JSONPath target" + #[test] + fn test_fern_merge_updates_into_schema() { + let doc = json!({ + "components": { "schemas": { "UserUpdate": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.UserUpdate", + json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "lastName": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result, + json!({ + "components": { "schemas": { "UserUpdate": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "lastName": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }}} + }) + ); + } + + /// Port of TS: "should merge arrays of objects in OpenAPI paths" + /// Uses filter expression to target a specific array element. + #[test] + fn test_fern_merge_array_element_by_filter() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[?(@.name=='id')]", + json!({ "name": "id", "in": "query", "required": true, "description": "Plant ID" }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/plants"]["get"]["parameters"], + json!([ + { "name": "id", "in": "query", "required": true, "description": "Plant ID" }, + { "name": "limit", "in": "query", "required": false } + ]) + ); + } + + /// Port of TS: "should replace arrays of primitives" + /// When both target and update are arrays, lodash-style index-by-index merge. + #[test] + fn test_fern_replace_primitive_arrays() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "tags": { + "type": "array", + "items": { "type": "string" }, + "enum": ["annual", "perennial"] + }} + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant.properties.tags.enum", + json!(["tropical", "succulent"]), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["tags"]["enum"], + json!(["tropical", "succulent"]) + ); + } + + /// Port of TS: "should ignore updates if remove is true" + #[test] + fn test_fern_remove_ignores_update() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "species": { "type": "string" }, + "toxicity": { "type": "string" } + } + }}} + }); + let overlay = make_overlay(vec![OverlayAction { + target: "$.components.schemas.Plant.properties.toxicity".into(), + description: None, + update: Some(json!({ "type": "string", "format": "enum" })), + remove: true, + }]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result, + json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "species": { "type": "string" } + } + }}} + }) + ); + } + + /// Port of TS: "should handle multiple consecutive array removals" + #[test] + fn test_fern_multiple_consecutive_array_removals() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false }, + { "name": "offset", "in": "query", "required": false }, + { "name": "sort", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![ + remove_action("$.paths['/plants'].get.parameters[?(@.name == 'limit')]"), + remove_action("$.paths['/plants'].get.parameters[?(@.name == 'offset')]"), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/plants"]["get"]["parameters"], + json!([ + { "name": "id", "in": "query", "required": true }, + { "name": "sort", "in": "query", "required": false } + ]) + ); + } + + /// Port of TS: "should handle merges to multiple items in an array" + #[test] + fn test_fern_merge_multiple_array_items_by_filter() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false }, + { "name": "authorization", "in": "header", "required": true }, + { "name": "offset", "in": "query", "required": false }, + { "name": "sort", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[?(@.in == 'query')]", + json!({ "description": "Query parameter" }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = result["paths"]["/plants"]["get"]["parameters"].as_array().unwrap(); + assert_eq!(params[0]["description"], "Query parameter"); + assert_eq!(params[1]["description"], "Query parameter"); + assert!(params[2].get("description").is_none()); // header param untouched + assert_eq!(params[3]["description"], "Query parameter"); + assert_eq!(params[4]["description"], "Query parameter"); + } + + /// Port of TS: "should handle multiple overlay actions" + #[test] + fn test_fern_multiple_overlay_actions() { + let doc = json!({ + "components": { "schemas": { + "PlantUpdate": { + "type": "object", + "properties": { "species": { "type": "string" } } + }, + "Plant": { + "type": "object", + "properties": { "id": { "type": "string" } } + } + }} + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.PlantUpdate", + json!({ + "type": "object", + "properties": { + "species": { "type": "string" }, + "color": { "type": "string" } + } + }), + ), + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["PlantUpdate"]["properties"]["color"].is_object()); + assert!(result["components"]["schemas"]["Plant"]["properties"]["species"].is_object()); + } + + /// Port of TS: "should handle actions on items inserted by earlier actions" + #[test] + fn test_fern_actions_on_items_from_earlier_actions() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "id": { "type": "string" } } + }}} + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "habitat": { + "type": "object", + "properties": { "climate": { "type": "string" } } + } + } + }), + ), + update_action( + "$.components.schemas.Plant.properties.habitat", + json!({ + "type": "object", + "properties": { + "climate": { "type": "string" }, + "soil": { "type": "string", "format": "enum" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let habitat = &result["components"]["schemas"]["Plant"]["properties"]["habitat"]["properties"]; + assert!(habitat["climate"].is_object()); + assert_eq!(habitat["soil"]["format"], "enum"); + } + + /// Port of TS: "should handle wildcard path matching across multiple paths" + #[test] + fn test_fern_wildcard_across_multiple_paths() { + let doc = json!({ + "paths": { + "/plants": { + "get": { "summary": "Get plants", "operationId": "getPlants" }, + "post": { "summary": "Create plant", "operationId": "createPlant" } + }, + "/gardens": { + "get": { "summary": "Get gardens", "operationId": "getGardens" } + }, + "/nurseries": { + "get": { "summary": "Get nurseries", "operationId": "getNurseries" }, + "delete": { "summary": "Delete nursery", "operationId": "deleteNursery" } + } + } + }); + let overlay = make_overlay(vec![update_action( + "$.paths.*.get", + json!({ "security": [{ "Bearer": [] }] }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // All GET operations should have security + assert!(result["paths"]["/plants"]["get"]["security"].is_array()); + assert!(result["paths"]["/gardens"]["get"]["security"].is_array()); + assert!(result["paths"]["/nurseries"]["get"]["security"].is_array()); + // Non-GET operations should not + assert!(result["paths"]["/plants"]["post"].get("security").is_none()); + assert!(result["paths"]["/nurseries"]["delete"].get("security").is_none()); + } + + /// Port of TS: "should handle zero-match JSONPath expressions" + #[test] + fn test_fern_zero_match_continues_processing() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" } + } + }}}, + "paths": { "/plants": { "get": { "summary": "Get plants" } } } + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.NonExistentSchema", + json!({ "type": "object" }), + ), + update_action( + "$.paths['/nonexistent'].post", + json!({ "summary": "Non-existent" }), + ), + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" }, + "color": { "type": "string", "format": "hex" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // Only the last valid action should have taken effect + assert!(result["components"]["schemas"]["Plant"]["properties"]["color"].is_object()); + // Original data untouched where no match + assert_eq!(result["paths"]["/plants"]["get"]["summary"], "Get plants"); + } + + /// Port of TS: "should handle deep merge behavior" + #[test] + fn test_fern_deep_merge_preserves_nested_structure() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "habitat": { + "type": "object", + "properties": { + "climate": { + "type": "object", + "properties": { + "temperature": { "type": "string" }, + "humidity": { "type": "integer" } + } + }, + "soil": { + "type": "object", + "properties": { "ph": { "type": "string" } } + } + } + }, + "care": { + "type": "object", + "properties": { + "watering": { "type": "string", "default": "weekly" } + } + } + } + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant.properties.habitat", + json!({ + "type": "object", + "properties": { + "climate": { + "type": "object", + "properties": { + "temperature": { "type": "string" }, + "rainfall": { "type": "string" } + } + }, + "soil": { + "type": "object", + "properties": { + "ph": { "type": "string" }, + "drainage": { "type": "string", "format": "enum" } + } + }, + "sunlight": { + "type": "object", + "properties": { + "hours": { "type": "integer", "default": 6 } + } + } + } + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let habitat = &result["components"]["schemas"]["Plant"]["properties"]["habitat"]["properties"]; + // Existing humidity preserved + assert_eq!(habitat["climate"]["properties"]["humidity"]["type"], "integer"); + // New rainfall added + assert_eq!(habitat["soil"]["properties"]["drainage"]["format"], "enum"); + // New sunlight section added + assert_eq!(habitat["sunlight"]["properties"]["hours"]["default"], 6); + // care section untouched + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["care"]["properties"]["watering"]["default"], + "weekly" + ); + } + + /// Port of TS: "should handle root-level targeting" + #[test] + fn test_fern_root_level_targeting() { + let doc = json!({ + "openapi": "3.0.0", + "info": { "title": "Plant API", "version": "1.0.0" }, + "paths": { "/plants": { "get": { "summary": "Get plants" } } }, + "tags": [{ "name": "legacy", "description": "Legacy endpoints" }], + "components": { "securitySchemes": { + "apiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + }} + }); + let overlay = make_overlay(vec![ + update_action( + "$", + json!({ + "openapi": "3.0.0", + "info": { + "title": "Plant API", + "version": "1.0.0", + "description": "API for managing plants and gardens", + "contact": { "name": "Garden Team", "email": "garden@example.com" } + }, + "servers": [ + { "url": "https://api.example.com/v1", "description": "Production" }, + { "url": "https://staging.example.com/v1", "description": "Staging" } + ], + "externalDocs": { + "description": "Plant care guide", + "url": "https://docs.example.com" + } + }), + ), + remove_action("$.tags"), + remove_action("$.components"), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // Added fields + assert_eq!(result["info"]["description"], "API for managing plants and gardens"); + assert!(result["servers"].is_array()); + assert_eq!(result["servers"].as_array().unwrap().len(), 2); + assert!(result["externalDocs"].is_object()); + // Removed fields + assert!(result.get("tags").is_none()); + assert!(result.get("components").is_none()); + // Preserved fields + assert_eq!(result["paths"]["/plants"]["get"]["summary"], "Get plants"); + } + + /// Port of TS: "should handle array edge cases including empty arrays and + /// replacing complete arrays" + #[test] + fn test_fern_array_edge_cases_append_and_replace() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string" }, + "enum": [] + }, + "zones": { + "type": "array", + "items": { "type": "string" }, + "enum": ["zone5"] + }, + "companions": { + "type": "array", + "items": { "type": "object" }, + "enum": [] + } + } + }}} + }); + let overlay = make_overlay(vec![ + // Replace whole tags object (including enum) via deep merge + update_action( + "$.components.schemas.Plant.properties.tags", + json!({ + "type": "array", + "items": { "type": "string" }, + "enum": ["tropical", "succulent"] + }), + ), + // Replace whole zones object (including enum) via deep merge + update_action( + "$.components.schemas.Plant.properties.zones", + json!({ + "type": "array", + "items": { "type": "string" }, + "enum": ["zone5", "zone6", "zone7"] + }), + ), + // Append object to empty companions array + update_action( + "$.components.schemas.Plant.properties.companions.enum", + json!({ "name": "basil", "benefit": "pest control" }), + ), + // Append another object + update_action( + "$.components.schemas.Plant.properties.companions.enum", + json!({ "name": "marigold", "benefit": "pollination" }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let props = &result["components"]["schemas"]["Plant"]["properties"]; + assert_eq!(props["tags"]["enum"], json!(["tropical", "succulent"])); + assert_eq!(props["zones"]["enum"], json!(["zone5", "zone6", "zone7"])); + let companions = props["companions"]["enum"].as_array().unwrap(); + assert_eq!(companions.len(), 2); + assert_eq!(companions[0]["name"], "basil"); + assert_eq!(companions[1]["name"], "marigold"); + } + + /// Port of TS: "should not mutate the input data object" + #[test] + fn test_fern_does_not_mutate_input() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "species": { "type": "string" } } + }}} + }); + let original = doc.clone(); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "species": { "type": "string" }, + "color": { "type": "string" } + } + }), + )]); + let _result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(doc, original); + } + + /// Port of TS: "should handle complex JSONPath expressions including + /// recursive descent and filters" — array index targeting + #[test] + fn test_fern_array_index_targeting() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer" } }, + { "name": "offset", "in": "query", "schema": { "type": "integer" } } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[0]", + json!({ + "name": "limit", "in": "query", + "schema": { "type": "integer", "minimum": 1, "maximum": 100 }, + "description": "Maximum number of items to return" + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = &result["paths"]["/plants"]["get"]["parameters"]; + assert_eq!(params[0]["description"], "Maximum number of items to return"); + assert_eq!(params[0]["schema"]["minimum"], 1); + // Second param untouched + assert!(params[1].get("description").is_none()); + } + + // -- Additional deep_merge tests for lodash parity -- + + /// Verify lodash-style index-by-index array merge + #[test] + fn test_deep_merge_arrays_index_by_index() { + let mut base = json!([1, 2, 3]); + let update = json!([10, 20]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([10, 20, 3])); + } + + /// Verify array merge with objects inside arrays + #[test] + fn test_deep_merge_arrays_of_objects() { + let mut base = json!([ + { "name": "a", "value": 1 }, + { "name": "b", "value": 2 } + ]); + let update = json!([ + { "name": "a", "value": 10, "extra": true } + ]); + deep_merge(&mut base, &update); + assert_eq!(base[0]["value"], 10); + assert_eq!(base[0]["extra"], true); + assert_eq!(base[1]["value"], 2); // second element preserved + } + + /// Verify array append appends objects to array target + #[test] + fn test_merge_at_path_array_append() { + let mut doc = json!({ "items": [] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!({ "id": 1 })); + merge_at_path(&mut doc, &segments, &json!({ "id": 2 })); + assert_eq!(doc["items"], json!([{ "id": 1 }, { "id": 2 }])); + } + + /// Verify that update with longer array extends the base + #[test] + fn test_deep_merge_update_extends_shorter_array() { + let mut base = json!([1]); + let update = json!([10, 20, 30]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([10, 20, 30])); + } + + // ----------------------------------------------------------------------- + // Item 1 verification: array append scope — widened guard pushes any + // non-array value (objects, strings, numbers, booleans, null) matching + // the Fern CLI TS behavior. + // ----------------------------------------------------------------------- + + #[test] + fn test_array_append_object() { + let mut doc = json!({ "items": [{"id": 1}] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!({"id": 2})); + assert_eq!(doc["items"], json!([{"id": 1}, {"id": 2}])); + } + + #[test] + fn test_array_append_string() { + let mut doc = json!({ "tags": ["a", "b"] }); + let segments = vec![PathSegment::Key("tags".into())]; + merge_at_path(&mut doc, &segments, &json!("c")); + assert_eq!(doc["tags"], json!(["a", "b", "c"])); + } + + #[test] + fn test_array_append_number() { + let mut doc = json!({ "nums": [1, 2] }); + let segments = vec![PathSegment::Key("nums".into())]; + merge_at_path(&mut doc, &segments, &json!(3)); + assert_eq!(doc["nums"], json!([1, 2, 3])); + } + + #[test] + fn test_array_append_boolean() { + let mut doc = json!({ "flags": [true] }); + let segments = vec![PathSegment::Key("flags".into())]; + merge_at_path(&mut doc, &segments, &json!(false)); + assert_eq!(doc["flags"], json!([true, false])); + } + + #[test] + fn test_array_append_null() { + let mut doc = json!({ "items": [1] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &Value::Null); + assert_eq!(doc["items"], json!([1, null])); + } + + #[test] + fn test_array_replace_with_array() { + let mut doc = json!({ "items": [1, 2] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!([10, 20, 30])); + // Arrays merge index-by-index via deep_merge + assert_eq!(doc["items"], json!([10, 20, 30])); + } + + // ----------------------------------------------------------------------- + // Item 2 verification: lodash merge vs deep_merge edge cases + // ----------------------------------------------------------------------- + + #[test] + fn test_deep_merge_arrays_of_arrays() { + let mut base = json!([[1, 2], [3, 4]]); + let update = json!([[10], [30, 40, 50]]); + deep_merge(&mut base, &update); + // Index-by-index: base[0] merges with [10], base[1] with [30,40,50] + assert_eq!(base, json!([[10, 2], [30, 40, 50]])); + } + + #[test] + fn test_deep_merge_mixed_type_arrays() { + let mut base = json!([1, "hello", {"a": 1}, [1, 2]]); + let update = json!([99, "world", {"b": 2}, [3]]); + deep_merge(&mut base, &update); + // Primitives replaced, objects merged, arrays merged index-by-index + assert_eq!(base, json!([99, "world", {"a": 1, "b": 2}, [3, 2]])); + } + + #[test] + fn test_deep_merge_sparse_like_arrays() { + // lodash.merge with sparse arrays fills gaps — our impl uses + // index-by-index so shorter base just gets extended + let mut base = json!([1]); + let update = json!([null, null, 3]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([null, null, 3])); + } + + #[test] + fn test_deep_merge_empty_arrays() { + let mut base = json!([1, 2, 3]); + let update = json!([]); + deep_merge(&mut base, &update); + // Empty update leaves base unchanged + assert_eq!(base, json!([1, 2, 3])); + } + + #[test] + fn test_deep_merge_nested_objects_in_arrays() { + let mut base = json!([{"a": {"x": 1}}, {"b": 2}]); + let update = json!([{"a": {"y": 2}}, {"c": 3}]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([{"a": {"x": 1, "y": 2}}, {"b": 2, "c": 3}])); + } + + #[test] + fn test_deep_merge_array_type_mismatch_replaces() { + // When base is object and update is array (or vice versa), replace + let mut base = json!({"a": 1}); + let update = json!([1, 2]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([1, 2])); + + let mut base = json!([1, 2]); + let update = json!({"a": 1}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 1})); + } + + // ----------------------------------------------------------------------- + // Item 3 verification: YAML ↔ JSON roundtrip fidelity + // ----------------------------------------------------------------------- + + #[test] + fn test_yaml_roundtrip_strips_comments() { + let yaml_with_comments = r#" +openapi: "3.0.0" +info: + title: Test # inline comment + version: "1.0" +# full line comment +paths: {} +"#; + // Need a no-op overlay to trigger the YAML->JSON->YAML roundtrip + // (empty overlay list short-circuits and returns original string) + let noop_overlay = r#" +overlay: "1.0.0" +info: + title: noop + version: "1.0.0" +actions: + - target: "$.__nonexistent__" + update: + x: 1 +"#; + let result = apply_overlays_to_spec( + yaml_with_comments, + &[noop_overlay.to_string()], + ) + .unwrap(); + // Comments are stripped after roundtrip + assert!(!result.contains("# inline comment"), "inline comment should be stripped: {result}"); + assert!(!result.contains("# full line comment"), "line comment should be stripped: {result}"); + assert!(result.contains("title: Test")); + } + + #[test] + fn test_yaml_roundtrip_resolves_anchors() { + // serde_yaml resolves anchors/aliases during deserialization. + // Use a simple alias (not merge key) to verify resolution. + let yaml_with_anchors = r#" +base_url: &url "https://api.example.com" +servers: + - url: *url + description: production +"#; + let yaml_value: serde_yaml::Value = + serde_yaml::from_str(yaml_with_anchors).unwrap(); + let json_val = yaml_to_json(yaml_value); + // Alias is resolved to the concrete value + assert_eq!( + json_val["servers"][0]["url"], + "https://api.example.com" + ); + assert_eq!( + json_val["servers"][0]["description"], + "production" + ); + // The anchor definition is also present as a regular key + assert_eq!( + json_val["base_url"], + "https://api.example.com" + ); + } + + #[test] + fn test_yaml_roundtrip_strips_custom_tags() { + let yaml_with_tag = r#" +value: !custom_tag + inner: data +"#; + let yaml_value: serde_yaml::Value = + serde_yaml::from_str(yaml_with_tag).unwrap(); + let json_val = yaml_to_json(yaml_value); + // Custom tags are stripped, value preserved + assert_eq!(json_val["value"]["inner"], "data"); + } + + #[test] + fn test_yaml_roundtrip_with_overlay_preserves_structure() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test API # comment will be stripped + version: "1.0" +paths: + /users: + get: + summary: List users +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: add-description + version: "1.0.0" +actions: + - target: "$.info" + update: + description: "Added by overlay" +"#; + let result = + apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); + assert!(result.contains("description: Added by overlay")); + assert!(result.contains("title: Test API")); + assert!(!result.contains('#')); + } + + // ----------------------------------------------------------------------- + // Item 4 verification: special characters in JSON keys via overlay paths + // ----------------------------------------------------------------------- + + #[test] + fn test_overlay_key_with_special_chars() { + let doc = json!({ + "x-extension": {"value": 1}, + "paths": { + "/users/{id}": { + "get": {"summary": "get user"} + } + } + }); + let overlay = make_overlay(vec![ + update_action( + "$.paths['/users/{id}'].get", + json!({"description": "Get a user by ID"}), + ), + update_action( + "$['x-extension']", + json!({"extra": true}), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/users/{id}"]["get"]["description"], + "Get a user by ID" + ); + assert_eq!(result["x-extension"]["extra"], true); + assert_eq!(result["x-extension"]["value"], 1); + } + + #[test] + fn test_normalized_path_to_segments_direct() { + // Verify the iterator-based approach works for keys with special chars + let doc = json!({ + "it's": {"nested": true}, + "key[0]": "bracket-key" + }); + let path = serde_json_path::JsonPath::parse("$[\"it's\"]").unwrap(); + let located = path.query_located(&doc); + for node in located.iter() { + let segments = normalized_path_to_segments(node.location()); + assert_eq!(segments, vec![PathSegment::Key("it's".into())]); + } + } + +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs new file mode 100644 index 000000000000..3cacb875f088 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs @@ -0,0 +1,8592 @@ +//! OpenAPI 3.0 Parser +//! +//! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` +//! representation used by the CLI command builder and executor. + +use std::collections::HashMap; + +use serde::{Deserialize, Deserializer}; + +use crate::text::to_kebab_flag; +use crate::openapi::discovery::{ + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, + JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, + RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, + StreamingConfig, +}; +use crate::error::CliError; + +/// Deserialize `x-fern-sdk-group-name` as either a string scalar or a list of +/// strings. The Fern extension allows both forms; some specs use +/// the scalar form while internal fixtures use the list form for nesting. +fn deserialize_group_name<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrList { + String(String), + List(Vec), + } + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(StringOrList::String(s)) => Ok(Some(vec![s])), + Some(StringOrList::List(v)) => Ok(Some(v)), + } +} + +// --------------------------------------------------------------------------- +// YAML deep-merge (Fern overrides support) +// --------------------------------------------------------------------------- + +/// Recursively deep-merge `overrides` onto `base`, matching the Fern CLI's +/// `mergeWithOverrides` behavior (lodash `mergeWith` + `omitDeepBy(isNull)`). +/// +/// Maps merge key-by-key (override wins on leaf collisions). Arrays of objects +/// merge element-by-element by index; if the override array is shorter the base +/// tail is kept, if longer the override tail is appended. Arrays of primitives +/// (or mixed) replace wholesale. Scalars replace. Null values in overrides +/// delete the key from the base; null removal is applied recursively. +/// Keys whose descendants preserve `null` values during the post-merge +/// null-removal pass. Matches the Fern CLI's `OPENAPI_EXAMPLES_KEYS` constant +/// used as `allowNullKeys` in `loadOpenAPI.ts`. +const ALLOW_NULL_KEYS: &[&str] = &[ + "examples", + "example", + "x-fern-examples", + "x-code-samples", + "x-codeSamples", +]; + +pub fn deep_merge_yaml( + base: serde_yaml::Value, + overrides: serde_yaml::Value, +) -> serde_yaml::Value { + let merged = deep_merge_yaml_inner(base, overrides); + remove_nulls(merged, false) +} + +/// Returns `true` if every element in the YAML sequence is a mapping (object). +fn all_objects(seq: &[serde_yaml::Value]) -> bool { + seq.iter().all(|v| v.is_mapping()) +} + +/// Core merge without null-removal (applied once at the top level). +fn deep_merge_yaml_inner( + base: serde_yaml::Value, + overrides: serde_yaml::Value, +) -> serde_yaml::Value { + match (base, overrides) { + (serde_yaml::Value::Mapping(mut base_map), serde_yaml::Value::Mapping(override_map)) => { + for (key, override_val) in override_map { + if let Some(base_val) = base_map.remove(&key) { + base_map.insert(key, deep_merge_yaml_inner(base_val, override_val)); + } else { + base_map.insert(key, override_val); + } + } + serde_yaml::Value::Mapping(base_map) + } + ( + serde_yaml::Value::Sequence(base_seq), + serde_yaml::Value::Sequence(override_seq), + ) => { + // Fern parity: arrays of objects are merged element-by-element + // (by index). Arrays of primitives (or mixed) replace wholesale. + if all_objects(&base_seq) && all_objects(&override_seq) { + let mut result: Vec = Vec::with_capacity( + std::cmp::max(base_seq.len(), override_seq.len()), + ); + let mut base_iter = base_seq.into_iter(); + let mut ovr_iter = override_seq.into_iter(); + loop { + match (base_iter.next(), ovr_iter.next()) { + (Some(b), Some(o)) => result.push(deep_merge_yaml_inner(b, o)), + (Some(b), None) => result.push(b), + (None, Some(o)) => result.push(o), + (None, None) => break, + } + } + serde_yaml::Value::Sequence(result) + } else { + serde_yaml::Value::Sequence(override_seq) + } + } + // All other types: override replaces the base. + (_base, override_val) => override_val, + } +} + +/// Recursively walk a YAML value and remove any key whose value is `null`. +/// This matches the Fern CLI's `omitDeepBy(isNull)` post-merge pass. +/// +/// When `allow_nulls` is `true` (i.e. we are inside a key listed in +/// `ALLOW_NULL_KEYS`, such as `"examples"`), null values are preserved +/// instead of being stripped. The flag propagates to all descendants. +fn remove_nulls(value: serde_yaml::Value, allow_nulls: bool) -> serde_yaml::Value { + match value { + serde_yaml::Value::Mapping(map) => { + let mut cleaned = serde_yaml::Mapping::new(); + for (k, v) in map { + let key_str = k.as_str().unwrap_or(""); + let child_allow = allow_nulls || ALLOW_NULL_KEYS.contains(&key_str); + if !child_allow && v.is_null() { + continue; + } + cleaned.insert(k, remove_nulls(v, child_allow)); + } + serde_yaml::Value::Mapping(cleaned) + } + serde_yaml::Value::Sequence(seq) => { + serde_yaml::Value::Sequence( + seq.into_iter().map(|v| remove_nulls(v, allow_nulls)).collect(), + ) + } + other => other, + } +} + +// --------------------------------------------------------------------------- +// Serde structs for OpenAPI 3.0 +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct OpenApiSpec { + info: OpenApiInfo, + #[serde(default)] + servers: Vec, + #[serde(default)] + paths: HashMap, + /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations + /// the *server* sends to the user (inbound from the CLI's perspective), + /// so they are captured but intentionally not lowered into CLI + /// subcommands. Any component schemas they reference remain reachable + /// via `components.schemas` regardless. + #[serde(default)] + webhooks: HashMap, + components: Option, + /// Spec-level default security. Each entry is an alternative; within an + /// entry the keys are scheme names (their values are the requested + /// OAuth2/OpenIDConnect scopes — empty arrays for HTTP/apiKey schemes). + /// Inherited by every operation that doesn't declare its own `security`. + #[serde(default)] + security: Option>>>, + /// Spec-root `x-fern-pagination` extension. Inherited by operations that + /// set `x-fern-pagination: true` instead of their own config block. + #[serde(default, rename = "x-fern-pagination")] + x_fern_pagination: Option, + /// Spec-root `x-fern-base-path` extension. Declares a common prefix + /// prepended to every operation path at request time. See + /// [`RestDescription::base_path`] for the runtime behavior. + #[serde(default, rename = "x-fern-base-path")] + x_fern_base_path: Option, + /// Spec-root [`x-fern-idempotency-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotency-headers) + /// extension. List of headers that idempotent operations accept. + #[serde(default, rename = "x-fern-idempotency-headers")] + x_fern_idempotency_headers: Option>, + /// Spec-root `x-fern-sdk-variables` extension. Lowered into + /// `RestDescription::sdk_variables` via `parse_sdk_variables`. + #[serde(default, rename = "x-fern-sdk-variables")] + x_fern_sdk_variables: Option, + /// Spec-root [`x-fern-retries`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/retries) + /// extension. May be a boolean shorthand (`true` enables defaults, + /// `false` disables) or an object describing the retry policy. + /// Inherited by every operation that omits its own block or sets it + /// to `true`. Mirrors upstream fern's `getFernRetriesExtension`, + /// extended with the optional `max_attempts` / `base_delay_ms` / + /// `factor` / `jitter` knobs the runtime retry loop consumes. + #[serde(default, rename = "x-fern-retries")] + x_fern_retries: Option, + /// Spec-root [`x-fern-global-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/global-headers) + /// extension. List of headers stamped on every outgoing request. + #[serde(default, rename = "x-fern-global-headers")] + x_fern_global_headers: Option>, + /// Spec-root [`x-fern-groups`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/groups) + /// extension. Mirrors the upstream Fern OpenAPI importer's + /// `getFernGroups.ts`: a record mapping group identifiers to + /// `{ summary?, description? }` metadata. Lowered into + /// [`RestDescription::groups`] (keyed by the kebab-cased identifier + /// so it matches the resource keys built from + /// `x-fern-sdk-group-name`). + #[serde(default, rename = "x-fern-groups")] + x_fern_groups: Option>, +} + +/// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. +/// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` +/// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). +#[derive(Debug, Deserialize, Clone)] +struct RawIdempotencyHeader { + /// HTTP header name (e.g. `Idempotency-Key`). Required. + header: String, + /// Optional SDK/CLI parameter name override. + #[serde(default)] + name: Option, + /// Optional environment variable name supplying a default value. + #[serde(default)] + env: Option, +} + +/// Raw deserialized form of a single entry in `x-fern-global-headers`. +/// Mirrors the upstream Fern OpenAPI importer's `GlobalHeaderExtension` +/// shape (`fern-api/fern` `getGlobalHeaders.ts`): `header` is the only +/// required field; everything else tunes the SDK/CLI surface. +/// +/// Both `default` and `x-fern-default` are accepted for the baked-in +/// fallback value. When both are present, `x-fern-default` wins — +/// mirroring the broader Fern convention where the prefixed extension +/// is the explicit form. +#[derive(Debug, Deserialize, Clone)] +struct RawGlobalHeader { + /// HTTP header name (e.g. `X-API-Version`). Required. + header: String, + /// Optional SDK/CLI parameter name override. Drives the kebab-cased + /// flag name when present (`apiVersion` → `--api-version`). + #[serde(default)] + name: Option, + /// When `true`, the header is omitted from outgoing requests when + /// no value resolves. Defaults to `false` (required). + #[serde(default)] + optional: Option, + /// Optional environment variable name supplying a fallback value. + #[serde(default)] + env: Option, + /// Optional baked-in default value. Surfaced in `--help` and sent + /// on the wire when neither the flag nor the env var is supplied. + #[serde(default)] + default: Option, + /// Alternate baked-in default. Wins over `default` when both are + /// present (mirrors `x-fern-default` precedence elsewhere in the + /// Fern OpenAPI importer). + #[serde(rename = "x-fern-default", default)] + x_fern_default: Option, +} + +/// Raw deserialized form of a single entry in the document-root +/// `x-fern-groups` map. Mirrors the upstream Fern OpenAPI importer's +/// `XFernGroupsSchema` zod schema (`getFernGroups.ts` → +/// `{ summary?: string, description?: string }`). +/// +/// Both fields are optional; the matching IR shape exposed by fern +/// (`SdkGroupInfo` in `finalIr.yml`) preserves them verbatim and the +/// `display-name` token shown in the JSDoc comment of fern's extension +/// enum is *not* part of the enforced schema — `summary` is the +/// real field name on the wire. +#[derive(Debug, Deserialize, Clone, Default)] +struct RawFernGroup { + /// Short human-friendly label for the group. Surfaces as the + /// clap subcommand's `about()` line when set. + #[serde(default)] + summary: Option, + /// Longer prose description for the group. Surfaces as the + /// clap subcommand's `long_about()` when set. + #[serde(default)] + description: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiInfo { + title: Option, + version: String, + description: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiServer { + url: String, + #[serde(default)] + description: Option, + /// Fern v2 spelling — the canonical extension name for naming a server. + /// When both v1 and v2 are present on the same entry, v1 wins to + /// mirror the upstream `fern-api/fern` OpenAPI importer, which + /// resolves the name via + /// `getExtension(server, [SERVER_NAME_V1, SERVER_NAME_V2])` — + /// `getExtension` returns the first matching key, so the v1 alias + /// `x-name` lands first. See + /// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts` + /// lines 72-75 and `.../src/getExtension.ts` lines 25-35. + #[serde(default, rename = "x-fern-server-name")] + x_fern_server_name: Option, + /// Fern v1 legacy alias. Recognized for backwards compatibility with + /// older specs that haven't migrated to `x-fern-server-name`. When + /// both extensions are present, this v1 spelling wins — see the + /// doc-comment on `x_fern_server_name` for the precedence citation. + #[serde(default, rename = "x-name")] + x_name: Option, +} + +impl OpenApiServer { + /// Resolve the server name, applying v1-over-v2 precedence to + /// match fern's `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` + /// first-match-wins behavior in + /// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`. + /// Each extension is trimmed and treated as "absent" when it is + /// the empty string (or whitespace-only) before the fallback runs, + /// so a blank `x-name: ""` does not shadow a valid + /// `x-fern-server-name` (and vice versa). An empty extension would + /// otherwise leak into clap as a blank-string possible value and a + /// blank `--help` row, which is always a spec bug — drop it at the + /// source so downstream code never needs to handle it. + fn resolved_name(&self) -> Option { + fn trimmed_non_empty(s: &Option) -> Option { + s.as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } + trimmed_non_empty(&self.x_name).or_else(|| trimmed_non_empty(&self.x_fern_server_name)) + } + + /// Lower the OpenAPI server entry into the internal + /// [`discovery::Server`] representation, applying the v1/v2 name + /// fallback (v1 wins; see [`Self::resolved_name`]). + fn to_discovery_server(&self) -> crate::openapi::discovery::Server { + crate::openapi::discovery::Server { + url: self.url.clone(), + name: self.resolved_name(), + description: self.description.clone(), + } + } +} + +#[derive(Debug, Deserialize, Default)] +struct OpenApiPathItem { + get: Option, + post: Option, + put: Option, + patch: Option, + delete: Option, + #[serde(default)] + parameters: Vec, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct OpenApiOperation { + #[serde(rename = "operationId")] + operation_id: Option, + summary: Option, + description: Option, + #[serde(default)] + parameters: Vec, + #[serde(rename = "requestBody")] + request_body: Option, + #[serde(default)] + servers: Vec, + #[serde(default)] + tags: Option>, + #[serde(rename = "x-fern-sdk-group-name", default, deserialize_with = "deserialize_group_name")] + x_fern_sdk_group_name: Option>, + #[serde(rename = "x-fern-sdk-method-name")] + x_fern_sdk_method_name: Option, + /// Operation-level security override. `Some(vec![])` is meaningful — it + /// explicitly opts the operation out of the spec-level default, marking + /// it anonymous. `None` means "inherit the spec default". + #[serde(default)] + security: Option>>>, + /// Operation-level `x-fern-pagination`. May be: + /// - an object describing cursor / offset / uri / path / custom pagination (overrides root) + /// - the literal `true` (inherits the spec-root config) + /// - missing (falls back to the document-wide pagination heuristic) + #[serde(default, rename = "x-fern-pagination")] + x_fern_pagination: Option, + /// Fern extension: when `Some(true)`, the operation is dropped from + /// the generated CLI surface — it does not appear as a subcommand, in + /// `--help`, or in completions. `None` (the default) and `Some(false)` + /// both keep the operation. Stored as `Option` to mirror the + /// nullish-coalescing precedence used at the parameter level. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/ignore + #[serde(rename = "x-fern-ignore", default)] + x_fern_ignore: Option, + /// OpenAPI standard `deprecated: true` flag on the operation. When + /// `x-fern-availability` is absent, a `true` here is lowered to + /// `Availability::Deprecated` so deprecated operations still surface + /// a `[DEPRECATED]` badge in help output. + #[serde(default)] + deprecated: bool, + /// Raw `x-fern-availability` extension on the operation. When present, + /// takes precedence over the standard `deprecated` flag. + #[serde(rename = "x-fern-availability", default)] + x_fern_availability: Option, + /// [`x-fern-idempotent: true`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotent) + /// marker. When `true`, the operation surfaces spec-root idempotency + /// headers as CLI flags; non-idempotent operations never send these + /// headers. + #[serde(rename = "x-fern-idempotent", default)] + x_fern_idempotent: Option, + /// Raw `x-fern-sdk-return-value` extension on the operation. Mirrors + /// fern-api/fern's `FernOpenAPIExtension.RESPONSE_PROPERTY` — a + /// dot-separated key path into the JSON response body identifying + /// the subvalue to surface to the caller. `None` (the default) + /// means the executor prints the full response. + #[serde(rename = "x-fern-sdk-return-value", default)] + x_fern_sdk_return_value: Option, + /// Raw operation-level `x-fern-streaming` extension. May be: + /// - the literal `true` (boolean shorthand → NDJSON, no terminator) + /// - the literal `false` (explicit opt-out) + /// - an object describing the stream (`format`, optional `terminator`, + /// and the `stream-condition` / `response-stream` / `response` keys + /// recognized upstream for parity — only `format` and `terminator` + /// affect runtime behavior) + /// - missing (no streaming) + /// + /// Resolved into [`StreamingConfig`] via `parse_streaming_extension`. + #[serde(rename = "x-fern-streaming", default)] + x_fern_streaming: Option, + /// Operation-level `x-fern-retries`. Same shape as the spec-root + /// block (boolean shorthand or object). A boolean defers to the + /// spec-root block; an object merges field-by-field over the + /// spec-root baseline. Missing inherits the spec root verbatim. + #[serde(default, rename = "x-fern-retries")] + x_fern_retries: Option, + /// Raw `x-fern-audiences` extension on the operation. Mirrors + /// fern-api/fern's OpenAPI importer + /// (`FernOpenAPIExtension.AUDIENCES = "x-fern-audiences"`): an + /// array of strings declaring which audiences the operation is + /// part of. Missing or empty means "no audience tag" — and is + /// filtered OUT when the binary's `main.rs` configures any preset + /// audience via [`crate::openapi::CliApp::audiences`], matching + /// fern's `audiences.some(a => operationAudiences.includes(a))` + /// check in `generateIr.ts:141` (which always evaluates false when + /// `operationAudiences` is `[]`). + #[serde(rename = "x-fern-audiences", default)] + x_fern_audiences: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum OpenApiParamOrRef { + /// A `$ref` to `components/parameters/`. The extension may also + /// be set on the ref-site object itself (Fern's overlay system and + /// OpenAPI 3.1 both allow extensions next to `$ref`); when present at + /// the ref site it wins over the resolved component's value. + Ref { + #[serde(rename = "$ref")] + ref_path: String, + #[serde(rename = "x-fern-ignore", default)] + x_fern_ignore: Option, + /// Fern extension: an alias used as the CLI flag name while the + /// wire name (the resolved component's `name`) is still used in + /// the outgoing request. Set on the ref-site object — wins over + /// the value on the resolved component via fern's `??` + /// precedence (mirrors the `IGNORE` extension above). + #[serde(rename = "x-fern-parameter-name", default)] + x_fern_parameter_name: Option, + /// Ref-site `x-fern-default` value. Wins over the value on the + /// resolved component parameter (and over the standard + /// schema-level `default:`). Mirrors fern's importer precedence: + /// `getExtension(parameter, FERN_DEFAULT) ?? getExtension(resolvedParameter, FERN_DEFAULT)`. + #[serde(rename = "x-fern-default", default)] + x_fern_default: Option, + }, + Inline(Box), +} + +#[derive(Debug, Deserialize, Default)] +struct OpenApiParameter { + name: String, + #[serde(rename = "in")] + location: Option, + #[serde(default)] + required: bool, + description: Option, + schema: Option, + #[serde(default)] + style: Option, + #[serde(default)] + explode: Option, + /// Fern extension: when `Some(true)`, the parameter is dropped from + /// the generated CLI surface — no CLI flag, not sent in the request. + /// Stored as `Option` so we can mirror fern's precedence: a + /// ref-site `x-fern-ignore` wins over the value on the resolved + /// component parameter via `ref_site.or(resolved).unwrap_or(false)`. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/ignore + #[serde(rename = "x-fern-ignore", default)] + x_fern_ignore: Option, + /// Fern extension: alias used as the CLI flag name while the wire + /// name (`name`) is kept on the outgoing HTTP request. Mirrors + /// fern's OpenAPI importer (`parameterNameOverride`) and supports + /// the same precedence as `x-fern-ignore`: a ref-site value wins + /// over the resolved component's via `ref_site.or(resolved)`. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/parameter-name + #[serde(rename = "x-fern-parameter-name", default)] + x_fern_parameter_name: Option, + /// OpenAPI standard `deprecated: true` flag on the parameter. When + /// `x-fern-availability` is absent, a `true` here is lowered to + /// `Availability::Deprecated` so deprecated parameter flags surface + /// a `[DEPRECATED]` badge in their `--help` description. + #[serde(default)] + deprecated: bool, + /// Raw `x-fern-availability` extension on the parameter. Takes + /// precedence over the standard `deprecated` flag. + #[serde(rename = "x-fern-availability", default)] + x_fern_availability: Option, + /// Fern extension: client-side default value for the parameter. + /// When present, the parameter becomes optional in the generated CLI + /// and the value is sent in the outgoing request when the user omits + /// the flag. Supports string, number, and boolean literals. + /// Wins over the standard `default:` on the parameter's `schema`. + /// A value placed at the **ref-site** (alongside `$ref`) wins over + /// the value on this resolved parameter — see `OpenApiParamOrRef::Ref`. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/default + #[serde(rename = "x-fern-default", default)] + x_fern_default: Option, + /// Fern extension binding this path parameter to a spec-level + /// `x-fern-sdk-variables` entry. Honored only on `in: path` + /// parameters (mirroring Fern's openapi-ir-parser). + #[serde(rename = "x-fern-sdk-variable", default)] + x_fern_sdk_variable: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct OpenApiParamSchema { + #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] + schema_type: Option, + #[serde(rename = "enum", default, deserialize_with = "deserialize_enum_values")] + enum_values: Option>, + default: Option, + format: Option, + /// Raw `x-fern-enum` map keyed by wire value, deserialized straight + /// off the YAML schema. Lowered to `discovery::FernEnumValue` in + /// `convert_fern_enum`. + #[serde(rename = "x-fern-enum", default)] + x_fern_enum: Option>, +} + +/// Raw `x-fern-enum` entry as it appears in the OpenAPI YAML. Kept +/// schema-faithful (the `casing` field is parsed-but-ignored) so the +/// shape matches the upstream Fern importer. +#[derive(Debug, Deserialize, Default)] +struct OpenApiFernEnumValue { + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + /// Parsed but not lowered — the SDK codegen uses `casing` to derive + /// language-specific identifiers; cli-sdk uses the raw display name. + #[serde(default)] + #[allow(dead_code)] + casing: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiRequestBody { + content: Option>, + #[serde(rename = "x-fern-parameter-name")] + x_fern_parameter_name: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiMediaType { + schema: Option, +} + +/// Captures the OpenAPI `type` field across the 3.0 string form +/// (`type: string`) and the 3.1 array form (`type: ["string", "null"]`). +/// `null_in_array` records whether `"null"` was present so nullability +/// can be reconstructed at access time. +#[derive(Debug, Default)] +struct TypeField { + schema_type: Option, + null_in_array: bool, +} + +impl<'de> Deserialize<'de> for TypeField { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de; + + struct TypeFieldVisitor; + + impl<'de> de::Visitor<'de> for TypeFieldVisitor { + type Value = TypeField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or array of strings") + } + + fn visit_str(self, v: &str) -> Result { + Ok(TypeField { schema_type: Some(v.to_string()), null_in_array: false }) + } + + fn visit_string(self, v: String) -> Result { + Ok(TypeField { schema_type: Some(v), null_in_array: false }) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut types: Vec = Vec::new(); + while let Some(t) = seq.next_element::()? { + types.push(t); + } + let null_in_array = types.iter().any(|t| t == "null"); + let schema_type = types.into_iter().find(|t| t != "null"); + Ok(TypeField { schema_type, null_in_array }) + } + + fn visit_none(self) -> Result { + Ok(TypeField::default()) + } + + fn visit_unit(self) -> Result { + Ok(TypeField::default()) + } + } + + deserializer.deserialize_any(TypeFieldVisitor) + } +} + +/// `exclusiveMinimum` / `exclusiveMaximum` switched semantics between +/// OpenAPI 3.0 (boolean: modifies the sibling `minimum`/`maximum`) and 3.1 +/// (numeric: the bound itself). This enum preserves the wire form so the +/// accessors above can resolve to a single numeric bound consistently. +#[derive(Debug, Clone, Copy)] +enum ExclusiveBound { + Flag(bool), + Value(f64), +} + +impl<'de> Deserialize<'de> for ExclusiveBound { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de; + + struct ExclusiveBoundVisitor; + + impl<'de> de::Visitor<'de> for ExclusiveBoundVisitor { + type Value = ExclusiveBound; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a boolean (OpenAPI 3.0) or a number (OpenAPI 3.1)") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(ExclusiveBound::Flag(v)) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(ExclusiveBound::Value(v as f64)) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(ExclusiveBound::Value(v as f64)) + } + + fn visit_f64(self, v: f64) -> Result { + Ok(ExclusiveBound::Value(v)) + } + } + + deserializer.deserialize_any(ExclusiveBoundVisitor) + } +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct OpenApiSchemaObject { + #[serde(rename = "$ref")] + schema_ref: Option, + /// Captures the wire `type` field in both its 3.0 string form and 3.1 + /// array form. Use `schema_type()` / `is_nullable()` instead of reading + /// directly — those accessors fold in the explicit `nullable` field. + #[serde(rename = "type", default)] + type_field: TypeField, + /// OpenAPI 3.0 explicit `nullable: true`. Removed in 3.1 (which expresses + /// the same idea via `"null"` in a type array). Both forms are surfaced + /// uniformly through `is_nullable()`. + #[serde(default)] + nullable: bool, + description: Option, + #[serde(default)] + properties: HashMap, + items: Option>, + #[serde(default)] + required: Vec, + #[serde(rename = "enum", default, deserialize_with = "deserialize_enum_values")] + enum_values: Option>, + /// OpenAPI 3.1 / JSON Schema 2020-12 `const`: a schema that matches a + /// single literal value. Lowered into a one-element `enum_values` by + /// `convert_schema_property` so existing enum-aware code paths handle + /// it without further changes. + #[serde(rename = "const", default)] + const_value: Option, + /// JSON Schema inclusive numeric lower bound. In OpenAPI 3.0 the + /// boolean `exclusiveMinimum: true` re-interprets this as an exclusive + /// bound; in 3.1 the two fields are independent. Use the + /// `inclusive_min` / `exclusive_min` accessors to resolve correctly. + #[serde(default)] + minimum: Option, + /// JSON Schema inclusive numeric upper bound. See `minimum` above for + /// 3.0 vs 3.1 interaction notes. + #[serde(default)] + maximum: Option, + /// `exclusiveMinimum` in either OpenAPI 3.0 boolean form or 3.1 + /// numeric form. Resolved via `exclusive_min()`. + #[serde(default)] + exclusive_minimum: Option, + /// `exclusiveMaximum` in either OpenAPI 3.0 boolean form or 3.1 + /// numeric form. Resolved via `exclusive_max()`. + #[serde(default)] + exclusive_maximum: Option, + /// OpenAPI 3.0 / 3.1 single `example` value. Captured for documentation + /// surfacing; not used by request execution. + #[serde(default)] + example: Option, + /// `examples` block, captured as raw YAML so that all three real-world + /// shapes load successfully: + /// - OpenAPI 3.1 array of values: `examples: [a, b]` + /// - OpenAPI 3.0 MediaType-style map: `examples: { name: { value: ... } }` + /// (technically out-of-spec at the schema level, but several + /// real-world specs embed this form) + /// - Single value + /// + /// Downstream code is free to interpret the value based on its shape. + #[serde(default)] + examples: Option, + /// JSON Schema composition: value must match exactly one branch. + /// Heavily used in 3.1 specs (where nullability via type arrays plus + /// composition replaces the 3.0 `nullable` flag for complex unions), + /// and also present in 3.0. + #[serde(default)] + one_of: Vec, + /// JSON Schema composition: value must match at least one branch. + #[serde(default)] + any_of: Vec, + /// JSON Schema composition: value must match every branch (typically + /// used for inheritance / mixin patterns). + #[serde(default)] + all_of: Vec, + format: Option, + #[serde(default)] + read_only: bool, + #[serde( + default, + deserialize_with = "deserialize_additional_properties" + )] + additional_properties: Option>, +} + +impl OpenApiSchemaObject { + /// The OpenAPI `type` value with any `"null"` array entry stripped. + /// Returns `None` when no type was given or when the type array + /// contained only `"null"`. + fn schema_type(&self) -> Option<&str> { + self.type_field.schema_type.as_deref() + } + + /// True when the schema is nullable per OpenAPI 3.0 (`nullable: true`) + /// or OpenAPI 3.1 (`"null"` in the type array). + fn is_nullable(&self) -> bool { + self.nullable || self.type_field.null_in_array + } + + /// Inclusive minimum, after applying the OpenAPI 3.0 rule that + /// `exclusiveMinimum: true` re-interprets `minimum` as exclusive. + fn inclusive_min(&self) -> Option { + match self.exclusive_minimum { + Some(ExclusiveBound::Flag(true)) => None, + _ => self.minimum, + } + } + + /// Inclusive maximum, with the same 3.0 re-interpretation rule applied. + fn inclusive_max(&self) -> Option { + match self.exclusive_maximum { + Some(ExclusiveBound::Flag(true)) => None, + _ => self.maximum, + } + } + + /// Exclusive lower bound resolved across both OpenAPI 3.0 + /// (boolean flag paired with `minimum`) and 3.1 (numeric form) wire + /// shapes. + fn exclusive_min(&self) -> Option { + match self.exclusive_minimum { + Some(ExclusiveBound::Value(n)) => Some(n), + Some(ExclusiveBound::Flag(true)) => self.minimum, + _ => None, + } + } + + /// Exclusive upper bound resolved across both wire shapes; see + /// `exclusive_min` for details. + fn exclusive_max(&self) -> Option { + match self.exclusive_maximum { + Some(ExclusiveBound::Value(n)) => Some(n), + Some(ExclusiveBound::Flag(true)) => self.maximum, + _ => None, + } + } +} + +/// Deserialize an OpenAPI `enum` field whose items may be strings, integers, or +/// booleans. Everything is coerced to `String`. +fn deserialize_enum_values<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct EnumVisitor; + + impl<'de> de::Visitor<'de> for EnumVisitor { + type Value = Option>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a sequence of scalar values") + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut values = Vec::new(); + while let Some(v) = seq.next_element::()? { + values.push(yaml_scalar_to_string(&v)); + } + Ok(Some(values)) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(EnumVisitor) +} + +/// Deserialize an OpenAPI `type` field that can be a plain string or an array +/// (e.g. `["string", "null"]` in OpenAPI 3.1). When it's an array, the first +/// non-`"null"` entry is used. +fn deserialize_type_field<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct TypeVisitor; + + impl<'de> de::Visitor<'de> for TypeVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or array of strings") + } + + fn visit_str(self, v: &str) -> Result { + Ok(Some(v.to_string())) + } + + fn visit_string(self, v: String) -> Result { + Ok(Some(v)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut types: Vec = Vec::new(); + while let Some(t) = seq.next_element::()? { + types.push(t); + } + Ok(types.into_iter().find(|t| t != "null")) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(TypeVisitor) +} + +/// Deserialize `additionalProperties` which can be a boolean or a schema object. +/// When it's `false`, we treat it as None. When `true`, we treat it as an empty schema. +fn deserialize_additional_properties<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct AdditionalPropertiesVisitor; + + impl<'de> de::Visitor<'de> for AdditionalPropertiesVisitor { + type Value = Option>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a boolean or a schema object") + } + + fn visit_bool(self, v: bool) -> Result { + if v { + Ok(Some(Box::new(OpenApiSchemaObject::default()))) + } else { + Ok(None) + } + } + + fn visit_map>(self, map: M) -> Result { + let obj = OpenApiSchemaObject::deserialize(de::value::MapAccessDeserializer::new(map))?; + Ok(Some(Box::new(obj))) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(AdditionalPropertiesVisitor) +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct OpenApiComponents { + #[serde(default)] + schemas: HashMap, + #[serde(default)] + parameters: HashMap, + #[serde(default)] + security_schemes: HashMap, +} + +/// Raw OpenAPI Security Scheme Object — the shape we deserialize. Lowered +/// to [`crate::openapi::discovery::SecurityScheme`] before being surfaced. +#[derive(Debug, Deserialize, Default)] +struct OpenApiSecurityScheme { + #[serde(rename = "type")] + type_field: Option, + /// `bearer` or `basic` for `type: http`. + scheme: Option, + /// `header`, `query`, or `cookie` for `type: apiKey`. + #[serde(rename = "in")] + location: Option, + /// Header/query/cookie name for `type: apiKey`. + name: Option, +} + +fn lower_security_scheme(raw: &OpenApiSecurityScheme) -> SecurityScheme { + let type_str = raw.type_field.as_deref().unwrap_or("").to_ascii_lowercase(); + match type_str.as_str() { + "http" => match raw.scheme.as_deref().map(str::to_ascii_lowercase).as_deref() { + Some("bearer") => SecurityScheme::HttpBearer, + Some("basic") => SecurityScheme::HttpBasic, + other => SecurityScheme::Other(format!("http/{}", other.unwrap_or(""))), + }, + "apikey" => { + let name = raw.name.clone().unwrap_or_default(); + match raw.location.as_deref().map(str::to_ascii_lowercase).as_deref() { + Some("header") => SecurityScheme::ApiKeyHeader { name }, + Some("query") => SecurityScheme::ApiKeyQuery { name }, + other => SecurityScheme::Other(format!("apiKey/{}", other.unwrap_or(""))), + } + } + "oauth2" => SecurityScheme::OAuth2, + other => SecurityScheme::Other(other.to_string()), + } +} + +// --------------------------------------------------------------------------- +// Helper: camelCase → kebab-case +/// Detect pagination config from the OpenAPI spec's components/parameters. +/// Looks for common patterns like "page_token" or "PageToken" params, +/// and checks response schemas for pagination objects. +fn detect_pagination_config(spec: &OpenApiSpec) -> (Option, Option) { + let components = match &spec.components { + Some(c) => c, + None => return (None, None), + }; + + // Check if there's a page_token parameter in components + for param in components.parameters.values() { + if param.name == "page_token" { + // Calendly-style: page_token query param, pagination.next_page_token response + return ( + Some("page_token".to_string()), + Some("pagination.next_page_token".to_string()), + ); + } + } + + (None, None) +} + +// --------------------------------------------------------------------------- +// x-fern-pagination: resolve per-operation pagination config from the +// OpenAPI extension. Mirrors the upstream Fern OpenAPI importer: +// https://github.com/fern-api/fern/blob/main/packages/cli/api-importers/openapi-to-ir/src/extensions/x-fern-pagination.ts +// --------------------------------------------------------------------------- + +const REQUEST_PREFIX: &str = "$request."; +const RESPONSE_PREFIX: &str = "$response."; + +/// Strip a leading `$request.` or `$response.` prefix from a JSONPath-style +/// reference. The runtime treats the remaining string as either a request +/// parameter name (for `$request.foo` → `foo`) or a dotted JSON path into +/// the response body (for `$response.pagination.next_cursor` → +/// `pagination.next_cursor`). +fn strip_pagination_prefix(value: &str) -> String { + value + .strip_prefix(REQUEST_PREFIX) + .or_else(|| value.strip_prefix(RESPONSE_PREFIX)) + .unwrap_or(value) + .to_string() +} + +/// Normalize a spec-level `x-fern-base-path` value: +/// - `None` and empty/whitespace-only strings collapse to `None`. +/// - Otherwise the string is trimmed of surrounding ASCII whitespace and +/// returned as-is (leading/trailing slashes preserved — `build_url` +/// normalizes them at request time). +fn normalize_base_path(raw: Option<&str>) -> Option { + let trimmed = raw?.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Resolve the `x-fern-pagination` extension for a single operation, +/// applying root-level inheritance. +/// +/// Mirrors upstream `fern-api/fern`'s `getFernPaginationExtension`: +/// - per-op block absent → `Ok(None)` (executor falls back to heuristic) +/// - per-op block is a boolean → look up the spec-root block +/// - root is a boolean too → `Err(...)` (matches upstream's +/// `CliError::ValidationError`) +/// - root is absent → `Ok(None)` (NOT an error — matches upstream) +/// - root is an object → parse the root block +/// - per-op block is an object → parse it directly +fn resolve_pagination_extension( + op_ext: Option<&serde_yaml::Value>, + root_ext: Option<&serde_yaml::Value>, + op_id: &str, +) -> Result, CliError> { + let value = match op_ext { + Some(v) => v, + None => return Ok(None), + }; + + if let serde_yaml::Value::Bool(_) = value { + return match root_ext { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::Bool(_)) => Err(CliError::Discovery(format!( + "Operation '{op_id}' sets `x-fern-pagination: ` but the spec-root \ + `x-fern-pagination` is also a boolean; the root must be an object describing \ + pagination (cursor / offset / next_uri / next_path / custom)." + ))), + Some(root) => parse_pagination_config(root, op_id, true), + }; + } + + parse_pagination_config(value, op_id, false) +} + +/// Parse a `x-fern-pagination` config object. Discrimination order mirrors +/// `fern-api/fern`'s `getPaginationExtension.ts`: +/// +/// 1. `cursor` → Cursor form +/// 2. `next_uri` → Uri form +/// 3. `next_path` → Path form +/// 4. `offset` → Offset form +/// 5. `type: "custom"` → Custom form +/// +/// Otherwise an "invalid pagination extension" error is returned, matching +/// upstream's `CliError`. +/// +/// `inherited` is purely used for error wording so the user can tell +/// whether the failure is in the per-op block or the inherited root block. +fn parse_pagination_config( + value: &serde_yaml::Value, + op_id: &str, + inherited: bool, +) -> Result, CliError> { + let map = match value { + serde_yaml::Value::Mapping(m) => m, + _ => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-pagination` for operation '{op_id}': expected an object, \ + got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(value) + ))); + } + }; + + if map.contains_key("cursor") { + let cursor = require_str_field(map, "cursor", op_id)?; + let next_cursor = require_str_field(map, "next_cursor", op_id)?; + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Cursor { + cursor: strip_pagination_prefix(&cursor), + next_cursor: strip_pagination_prefix(&next_cursor), + results: strip_pagination_prefix(&results), + })); + } + + if map.contains_key("next_uri") { + let next_uri = require_str_field(map, "next_uri", op_id)?; + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Uri { + next_uri: strip_pagination_prefix(&next_uri), + results: strip_pagination_prefix(&results), + })); + } + + if map.contains_key("next_path") { + let next_path = require_str_field(map, "next_path", op_id)?; + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Path { + next_path: strip_pagination_prefix(&next_path), + results: strip_pagination_prefix(&results), + })); + } + + if map.contains_key("offset") { + let offset = require_str_field(map, "offset", op_id)?; + let results = require_str_field(map, "results", op_id)?; + let step = optional_str_field(map, "step", op_id)?; + let has_next_page = optional_str_field(map, "has-next-page", op_id)?; + return Ok(Some(PaginationConfig::Offset { + offset: strip_pagination_prefix(&offset), + results: strip_pagination_prefix(&results), + step: step.map(|s| strip_pagination_prefix(&s)), + has_next_page: has_next_page.map(|s| strip_pagination_prefix(&s)), + })); + } + + if matches!( + map.get(serde_yaml::Value::String("type".to_string())), + Some(serde_yaml::Value::String(t)) if t == "custom" + ) { + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Custom { + results: strip_pagination_prefix(&results), + })); + } + + Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': must declare one of `cursor`, \ + `next_uri`, `next_path`, `offset`, or `type: custom`. See \ + https://buildwithfern.com/learn/api-definitions/openapi/extensions/pagination" + ))) +} + +fn require_str_field( + map: &serde_yaml::Mapping, + field: &str, + op_id: &str, +) -> Result { + match map.get(serde_yaml::Value::String(field.to_string())) { + Some(serde_yaml::Value::String(s)) => Ok(s.clone()), + Some(other) => Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': field `{field}` must be \ + a string, got {}.", + describe_yaml_kind(other) + ))), + None => Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': missing required field \ + `{field}`." + ))), + } +} + +fn optional_str_field( + map: &serde_yaml::Mapping, + field: &str, + op_id: &str, +) -> Result, CliError> { + match map.get(serde_yaml::Value::String(field.to_string())) { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::String(s)) => Ok(Some(s.clone())), + Some(other) => Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': field `{field}` must be \ + a string when present, got {}.", + describe_yaml_kind(other) + ))), + } +} + +// --------------------------------------------------------------------------- +// x-fern-streaming: resolve per-operation streaming config from the OpenAPI +// extension. Mirrors the upstream Fern OpenAPI importer: +// https://github.com/fern-api/fern/blob/main/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernStreamingExtension.ts +// --------------------------------------------------------------------------- + +/// Resolve `x-fern-streaming` for a single operation. Returns: +/// - `Ok(None)` — extension absent, or set to literal `false` (explicit opt-out). +/// - `Ok(Some(_))` — streaming enabled; runtime variant captures format + terminator. +/// - `Err(...)` — invalid shape (non-bool/non-object, unknown `format`, etc.). +/// +/// Boolean shorthand (`x-fern-streaming: true`) maps to NDJSON +/// (`StreamingConfig::Json`) with no terminator. This matches the +/// upstream importer's boolean handler exactly — see the +/// `getFernStreamingExtension.ts` comment that the boolean shorthand +/// emits `format: "json"` (so that callers who haven't picked a wire +/// format don't accidentally inherit OpenAI-style SSE semantics). +fn parse_streaming_extension( + value: Option<&serde_yaml::Value>, + op_id: &str, +) -> Result, CliError> { + let value = match value { + Some(v) => v, + None => return Ok(None), + }; + + if let serde_yaml::Value::Bool(b) = value { + return if *b { + Ok(Some(StreamingConfig::Json { terminator: None })) + } else { + Ok(None) + }; + } + + let map = match value { + serde_yaml::Value::Mapping(m) => m, + other => { + return Err(CliError::Discovery(format!( + "Invalid `x-fern-streaming` for operation '{op_id}': expected a boolean or \ + an object, got {}.", + describe_yaml_kind(other) + ))); + } + }; + + // `format` is optional in upstream's object schema. The upstream + // importer and the typed SDKs (TS / C#) default a format-less + // object to `json` (NDJSON), matching the boolean shorthand. The + // CLI mirrors that default so callers who omit `format` get the + // same wire shape as the typed SDKs would have produced. + let format = optional_str_field_named(map, "format", op_id, "x-fern-streaming")?; + let format = match format.as_deref() { + Some("sse") => StreamingFormat::Sse, + Some("json") | None => StreamingFormat::Json, + Some("text") => StreamingFormat::Text, + Some(other) => { + return Err(CliError::Discovery(format!( + "Invalid `x-fern-streaming` for operation '{op_id}': field `format` must be \ + `sse`, `json`, or `text`, got `{other}`." + ))); + } + }; + + let terminator = + optional_str_field_named(map, "terminator", op_id, "x-fern-streaming")?; + + if matches!(format, StreamingFormat::Text) && terminator.is_some() { + // Mirrors the IR (`TextStreamChunk` carries no `terminator` + // field) and the typed SDK generators — surfacing it at parse + // time keeps misconfigurations from silently no-op'ing at + // runtime. + return Err(CliError::Discovery(format!( + "Invalid `x-fern-streaming` for operation '{op_id}': field `terminator` is not \ + supported for `format: text` streams." + ))); + } + + Ok(Some(match format { + StreamingFormat::Sse => StreamingConfig::Sse { terminator }, + StreamingFormat::Json => StreamingConfig::Json { terminator }, + StreamingFormat::Text => StreamingConfig::Text, + })) +} + +enum StreamingFormat { + Sse, + Json, + Text, +} + +fn optional_str_field_named( + map: &serde_yaml::Mapping, + field: &str, + op_id: &str, + extension: &str, +) -> Result, CliError> { + match map.get(serde_yaml::Value::String(field.to_string())) { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::String(s)) => Ok(Some(s.clone())), + Some(other) => Err(CliError::Discovery(format!( + "Invalid `{extension}` for operation '{op_id}': field `{field}` must be a string \ + when present, got {}.", + describe_yaml_kind(other) + ))), + } +} + +// --------------------------------------------------------------------------- +// x-fern-retries: resolve per-operation retry policy from the OpenAPI +// extension. Mirrors the upstream Fern OpenAPI importer's tagged shape +// (`getFernRetriesExtension.ts` — `{ disabled: bool }`) and extends it with +// the optional knobs the cli-sdk runtime retry loop consumes (max attempts, +// backoff base, factor, jitter). The extra knobs are forward-compatible with +// the upstream importer. +// --------------------------------------------------------------------------- + +/// Resolve the `x-fern-retries` extension for a single operation, applying +/// root-level inheritance and per-operation overrides. +/// +/// Precedence — matches the pagination resolver's shape and the upstream +/// fern importer's nullish coalescing: +/// - per-op block absent → inherit the spec-root block (or `None` when also absent) +/// - per-op `true` → spec-root config, or all-defaults when root is also absent +/// - per-op `false` (or `{ disabled: true }`) → disabled regardless of root +/// - per-op object → root values, overridden field-by-field by the op block; +/// when root is also `true`/absent the op object stacks on top of defaults +fn resolve_retries_extension( + op_ext: Option<&serde_yaml::Value>, + root_ext: Option<&serde_yaml::Value>, + op_id: &str, +) -> Result, CliError> { + // Build the baseline from the root block, if any. Root-`false` / + // `{ disabled: true }` propagates by default to operations that don't + // override it. + let root_baseline = match root_ext { + None | Some(serde_yaml::Value::Null) => None, + Some(v) => parse_retries_value(v, op_id, /*inherited=*/ true)?, + }; + + let op = match op_ext { + // Op missing → inherit the root baseline (or `None` when also absent). + Some(v) => v, + None => return Ok(root_baseline), + }; + + // Op is a boolean. + if let serde_yaml::Value::Bool(b) = op { + if !*b { + // `false` disables retries on this operation regardless of root. + return Ok(Some(RetriesConfig::disabled())); + } + // `true` adopts the root baseline; falls back to all-defaults when + // root is absent or also a boolean. + return Ok(Some(root_baseline.unwrap_or_default())); + } + + // Op is an object. The root baseline (if enabled) is the starting + // config; the op fields override field-by-field. When the root is + // explicitly disabled, the op block re-enables retries (the more + // specific block wins). + let baseline = match root_baseline { + Some(cfg) if cfg.enabled => cfg, + _ => RetriesConfig::default(), + }; + + let map = match op { + serde_yaml::Value::Mapping(m) => m, + other => { + return Err(CliError::Discovery(format!( + "Invalid operation-level `x-fern-retries` for operation '{op_id}': expected \ + an object or boolean, got {}.", + describe_yaml_kind(other) + ))); + } + }; + + let config = apply_retries_object(baseline, map, op_id, /*inherited=*/ false)?; + + // `max_attempts: 0` is treated identically to `disabled: true` so the + // executor doesn't have to special-case the count itself. + if config.max_attempts == 0 { + return Ok(Some(RetriesConfig::disabled())); + } + + Ok(Some(config)) +} + +/// Parse a standalone `x-fern-retries` value (root or operation) into a +/// [`RetriesConfig`]. Used for the root baseline: takes the raw extension +/// value and returns the resolved config (or `None` when the value is +/// `null`). Bool/object are handled inline; unknown shapes error out. +fn parse_retries_value( + value: &serde_yaml::Value, + op_id: &str, + inherited: bool, +) -> Result, CliError> { + match value { + serde_yaml::Value::Null => Ok(None), + serde_yaml::Value::Bool(true) => Ok(Some(RetriesConfig::default())), + serde_yaml::Value::Bool(false) => Ok(Some(RetriesConfig::disabled())), + serde_yaml::Value::Mapping(map) => { + let config = + apply_retries_object(RetriesConfig::default(), map, op_id, inherited)?; + if config.max_attempts == 0 { + return Ok(Some(RetriesConfig::disabled())); + } + Ok(Some(config)) + } + other => Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': expected an object or \ + boolean, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))), + } +} + +/// Apply the fields of an `x-fern-retries` object on top of an existing +/// [`RetriesConfig`]. Unknown keys are ignored (forward-compatible). +fn apply_retries_object( + mut config: RetriesConfig, + map: &serde_yaml::Mapping, + op_id: &str, + inherited: bool, +) -> Result { + // Canonical fern shape: `{ disabled: true | false }`. + if let Some(v) = map.get(serde_yaml::Value::String("disabled".to_string())) { + match v { + serde_yaml::Value::Bool(disabled) => { + if *disabled { + return Ok(RetriesConfig::disabled()); + } + config.enabled = true; + } + other => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `disabled` \ + must be a boolean, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))); + } + } + } + + // `max` / `max_attempts` / `max-attempts` — accept all three spellings + // since the upstream IR has not yet settled on one; the fern docs + // refer to "max retry attempts" colloquially. + if let Some(v) = retries_field(map, &["max_attempts", "max-attempts", "max"]) { + let parsed = match v { + serde_yaml::Value::Number(n) => n.as_u64(), + other => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `max_attempts` \ + must be a non-negative integer, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))); + } + }; + let parsed = parsed.ok_or_else(|| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `max_attempts` \ + must be a non-negative integer.", + if inherited { "inherited" } else { "operation-level" }, + )) + })?; + config.max_attempts = u32::try_from(parsed).map_err(|_| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `max_attempts` \ + must fit in a u32, got {parsed}.", + if inherited { "inherited" } else { "operation-level" }, + )) + })?; + } + + if let Some(v) = retries_field(map, &["base_delay_ms", "base-delay-ms", "base"]) { + let parsed = match v { + serde_yaml::Value::Number(n) => n.as_u64().ok_or_else(|| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field \ + `base_delay_ms` must be a non-negative integer.", + if inherited { "inherited" } else { "operation-level" }, + )) + })?, + other => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field \ + `base_delay_ms` must be an integer, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))); + } + }; + config.base_delay_ms = parsed; + } + + if let Some(v) = retries_field(map, &["factor", "backoff_factor", "backoff-factor"]) { + let parsed = retries_required_f64(v, "factor", op_id, inherited)?; + if parsed < 1.0 { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `factor` must be \ + >= 1.0, got {parsed}.", + if inherited { "inherited" } else { "operation-level" }, + ))); + } + config.factor = parsed; + } + + if let Some(v) = retries_field(map, &["jitter"]) { + let parsed = retries_required_f64(v, "jitter", op_id, inherited)?; + if !(0.0..=1.0).contains(&parsed) { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `jitter` must be \ + in [0.0, 1.0], got {parsed}.", + if inherited { "inherited" } else { "operation-level" }, + ))); + } + config.jitter = parsed; + } + + Ok(config) +} + +/// First-of-aliases lookup for `x-fern-retries` field reads. Returns the +/// first matching value (any present alias) so authors can use either +/// `max_attempts` / `max-attempts` / `max` (or the corresponding +/// `base_delay_ms` / `base-delay-ms` / `base`) interchangeably. +fn retries_field<'a>( + map: &'a serde_yaml::Mapping, + aliases: &[&str], +) -> Option<&'a serde_yaml::Value> { + for alias in aliases { + if let Some(v) = map.get(serde_yaml::Value::String((*alias).to_string())) { + return Some(v); + } + } + None +} + +fn retries_required_f64( + value: &serde_yaml::Value, + field: &str, + op_id: &str, + inherited: bool, +) -> Result { + match value { + serde_yaml::Value::Number(n) => n.as_f64().ok_or_else(|| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `{field}` must be \ + a finite number.", + if inherited { "inherited" } else { "operation-level" }, + )) + }), + other => Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `{field}` must be a \ + number, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))), + } +} + +fn describe_yaml_kind(value: &serde_yaml::Value) -> &'static str { + match value { + serde_yaml::Value::Null => "null", + serde_yaml::Value::Bool(_) => "boolean", + serde_yaml::Value::Number(_) => "number", + serde_yaml::Value::String(_) => "string", + serde_yaml::Value::Sequence(_) => "array", + serde_yaml::Value::Mapping(_) => "object", + serde_yaml::Value::Tagged(_) => "tagged value", + } +} + +// --------------------------------------------------------------------------- + +fn camel_to_kebab(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for ch in s.chars() { + if !ch.is_ascii_alphanumeric() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } else if ch.is_uppercase() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + while result.ends_with('-') { + result.pop(); + } + result +} + +/// Tokenize a string the way Fern's OpenAPI importer does: camelCase-only +/// strings split on each capital letter; everything else splits on +/// non-alphanumeric runs. All tokens lowercased, empties dropped. +fn tokenize(s: &str) -> Vec { + let is_camel_case = s.chars().next().is_some_and(|c| c.is_ascii_lowercase()) + && s.chars().all(|c| c.is_ascii_alphanumeric()) + && s.chars().any(|c| c.is_ascii_uppercase()); + + let raw: Vec = if is_camel_case { + let mut tokens = Vec::new(); + let mut current = String::new(); + for c in s.chars() { + if c.is_ascii_uppercase() && !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + current.push(c); + } + if !current.is_empty() { + tokens.push(current); + } + tokens + } else { + s.split(|c: char| !c.is_ascii_alphanumeric()) + .map(str::to_string) + .collect() + }; + + raw.into_iter() + .filter(|t| !t.is_empty()) + .map(|t| t.to_lowercase()) + .collect() +} + +/// Inject one synthetic header `MethodParameter` per spec-root +/// idempotency header into an idempotent operation's parameter map. The +/// existing header-parameter pathway in `commands.rs` and `executor.rs` +/// then handles flag exposure (kebab-cased `--`) and on-the-wire +/// header transmission (`location: "header"`). +/// +/// The parameter key (HashMap key) is the on-the-wire header name +/// (used directly as the HTTP header). The kebab-cased `--` +/// derives from [`IdempotencyHeader::name`] when present +/// (`MethodParameter.flag_name_override`), otherwise from the header. +/// This mirrors the upstream Fern OpenAPI importer, where `name` +/// becomes the SDK parameter identifier. +/// +/// Spec-declared parameters with the same HashMap key win — we do not +/// overwrite them, which preserves any per-operation customization +/// (e.g. an `Idempotency-Key` param declared explicitly in `parameters:` +/// with a custom description). +fn inject_idempotency_header_params( + params: &mut HashMap, + idempotency_headers: &[IdempotencyHeader], +) { + for h in idempotency_headers { + if params.contains_key(&h.header) { + continue; + } + let description = h + .name + .as_ref() + .map(|n| format!("Idempotency header `{}` (param `{}`).", h.header, n)) + .unwrap_or_else(|| format!("Idempotency header `{}`.", h.header)); + let flag_name_override = h.name.as_ref().map(|n| to_kebab_flag(n)); + params.insert( + h.header.clone(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some(description), + location: Some("header".to_string()), + env_var: h.env.clone(), + flag_name_override, + ..Default::default() + }, + ); + } +} + +/// Mirror Fern's OpenAPI importer behavior: when an operation's group is +/// derived from a tag (no `x-fern-sdk-group-name`), strip tag tokens that +/// prefix the operationId. `tag="Customers", operationId="customersList"` +/// → `list`. No-op when the operationId doesn't start with the tag tokens. +fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { + let tag_tokens = tokenize(tag); + let op_tokens = tokenize(operation_id); + if tag_tokens.is_empty() || op_tokens.len() <= tag_tokens.len() { + return operation_id.to_string(); + } + for (i, t) in tag_tokens.iter().enumerate() { + if op_tokens.get(i) != Some(t) { + return operation_id.to_string(); + } + } + op_tokens[tag_tokens.len()..].join("-") +} + +// --------------------------------------------------------------------------- +// Schema conversion helpers +// --------------------------------------------------------------------------- + +/// Resolve effective enum values for a schema, combining the OpenAPI `enum` +/// field with the OpenAPI 3.1 / JSON Schema 2020-12 `const` keyword. A +/// present `const` is lowered into a one-element enum so existing +/// enum-aware code paths (CLI flag value validation, help rendering) pick +/// it up without further changes. An explicit `enum` wins over `const` +/// when both are present. +fn effective_enum_values(obj: &OpenApiSchemaObject) -> Option> { + if let Some(values) = &obj.enum_values { + return Some(values.clone()); + } + let const_value = obj.const_value.as_ref()?; + Some(vec![yaml_scalar_to_string(const_value)]) +} + +/// Lower an `oneOf` / `anyOf` / `allOf` array of OpenAPI schemas into the +/// IR's `JsonSchemaProperty` form. Used by both `convert_schema_object` +/// (component-schema root) and `convert_schema_property` (nested property). +fn convert_composition_branches(branches: &[OpenApiSchemaObject]) -> Vec { + branches.iter().map(convert_schema_property).collect() +} + +/// If `obj` has an OpenAPI 3.1 / JSON Schema 2020-12 `const`, return the +/// const as a typed JSON value to install as the CLI flag's client-side +/// default. Pairs with the const→single-element enum lowering in +/// `effective_enum_values`: the flag accepts exactly the const value (or +/// rejects everything else via the enum parser), and becomes optional +/// because omitting it auto-injects the const at request time. +fn const_default_value(obj: &OpenApiSchemaObject) -> Option { + yaml_value_to_json(obj.const_value.as_ref()?) +} + +/// Coerce a YAML scalar (string, number, boolean) to its string form for +/// downstream use in CLI flag enumerations. Non-scalars fall back to the +/// Debug rendering — callers only invoke this on values that should be +/// scalar by spec, so the fallback is a diagnostic, not a feature. +fn yaml_scalar_to_string(v: &serde_yaml::Value) -> String { + match v { + serde_yaml::Value::String(s) => s.clone(), + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + other => format!("{other:?}"), + } +} + +fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { + if let Some(ref_path) = &obj.schema_ref { + let name = strip_ref_prefix(ref_path); + return JsonSchema { + schema_ref: Some(name), + ..Default::default() + }; + } + + let properties = obj + .properties + .iter() + .map(|(k, v)| (k.clone(), convert_schema_property(v))) + .collect(); + + JsonSchema { + id: None, + schema_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), + description: obj.description.clone(), + properties, + schema_ref: None, + items: obj.items.as_ref().map(|i| Box::new(convert_schema_property(i))), + required: obj.required.clone(), + one_of: convert_composition_branches(&obj.one_of), + any_of: convert_composition_branches(&obj.any_of), + all_of: convert_composition_branches(&obj.all_of), + additional_properties: obj + .additional_properties + .as_ref() + .map(|ap| Box::new(convert_schema_property(ap))), + } +} + +fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { + if let Some(ref_path) = &obj.schema_ref { + let name = strip_ref_prefix(ref_path); + return JsonSchemaProperty { + schema_ref: Some(name), + ..Default::default() + }; + } + + let properties = obj + .properties + .iter() + .map(|(k, v)| (k.clone(), convert_schema_property(v))) + .collect(); + + JsonSchemaProperty { + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), + description: obj.description.clone(), + schema_ref: None, + format: obj.format.clone(), + items: obj.items.as_ref().map(|i| Box::new(convert_schema_property(i))), + properties, + read_only: obj.read_only, + default: None, + enum_values: effective_enum_values(obj), + minimum: obj.inclusive_min(), + maximum: obj.inclusive_max(), + exclusive_minimum: obj.exclusive_min(), + exclusive_maximum: obj.exclusive_max(), + example: obj.example.clone(), + examples: obj.examples.clone(), + one_of: convert_composition_branches(&obj.one_of), + any_of: convert_composition_branches(&obj.any_of), + all_of: convert_composition_branches(&obj.all_of), + additional_properties: obj + .additional_properties + .as_ref() + .map(|ap| Box::new(convert_schema_property(ap))), + } +} + +fn strip_ref_prefix(ref_path: &str) -> String { + // Handles "#/components/schemas/Foo" and "#/components/parameters/Foo" + ref_path + .rsplit('/') + .next() + .unwrap_or(ref_path) + .to_string() +} + +// --------------------------------------------------------------------------- +// x-fern-global-headers +// --------------------------------------------------------------------------- + +/// Lower a YAML scalar (string, integer, float, bool) used as a global +/// header's `default` into the on-the-wire string form. Returns `None` +/// for nulls, sequences, and mappings — those shapes aren't meaningful +/// as an HTTP header value, so we drop them rather than send something +/// nonsensical like `Some(["a","b"])` on the wire. +fn lower_global_header_default(value: &serde_yaml::Value) -> Option { + match value { + serde_yaml::Value::String(s) => Some(s.clone()), + serde_yaml::Value::Bool(b) => Some(b.to_string()), + serde_yaml::Value::Number(n) => Some(n.to_string()), + // Null, Sequence, Mapping, Tagged — not a valid header value. + _ => None, + } +} + +/// Lower the spec-root `x-fern-global-headers` block into the canonical +/// [`GlobalHeader`] discovery types. Mirrors the upstream Fern OpenAPI +/// importer's `getGlobalHeaders.ts`: entries without a `header` are +/// rejected at deserialize-time by serde; everything else is optional +/// and falls back to sensible defaults (required, no env, no default). +/// +/// `x-fern-default` wins over `default` when both are present. +fn lower_global_headers(raws: &[RawGlobalHeader]) -> Vec { + raws.iter() + .map(|raw| { + let default_yaml = raw.x_fern_default.as_ref().or(raw.default.as_ref()); + GlobalHeader { + header: raw.header.clone(), + name: raw.name.clone(), + optional: raw.optional.unwrap_or(false), + env: raw.env.clone(), + default: default_yaml.and_then(lower_global_header_default), + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// x-fern-groups +// --------------------------------------------------------------------------- + +/// Lower the document-root `x-fern-groups` block into the canonical +/// [`SdkGroupInfo`] discovery type, keyed by the kebab-cased group +/// identifier so it matches the resource-tree keys built from +/// `x-fern-sdk-group-name`. +/// +/// Mirrors fern's `getFernGroups.ts` / `SdkGroupInfo` IR shape +/// (`{ summary?, description? }`). Entries are kept verbatim — fern +/// does not invent additional fields, and neither do we. Empty +/// entries (both fields `None`) are preserved so the lookup tells +/// "no metadata" from "explicitly empty metadata", though both +/// render the same in `--help` today. +fn lower_fern_groups(raws: &HashMap) -> HashMap { + raws.iter() + .map(|(key, raw)| { + ( + camel_to_kebab(key), + SdkGroupInfo { + summary: raw.summary.clone(), + description: raw.description.clone(), + }, + ) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// x-fern-sdk-variables +// --------------------------------------------------------------------------- + +/// Lower the spec-root `x-fern-sdk-variables` block into a flat list of +/// [`SdkVariable`] entries. Mirrors Fern's openapi-ir-parser +/// `getVariableDefinitions.ts`: each variable is keyed by name, declares +/// a schema with `type` and optional `description`, and is only honored +/// when `type` is `string`. Non-string entries are logged and dropped so +/// the rest of the spec still loads — matching the upstream importer's +/// `Variable has unsupported schema` behavior without failing +/// the whole spec load (the CLI is intentionally permissive). +fn parse_sdk_variables(mapping: Option<&serde_yaml::Mapping>) -> Vec { + let Some(mapping) = mapping else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(mapping.len()); + for (name_val, schema_val) in mapping { + let name = match name_val.as_str() { + Some(s) => s.to_string(), + None => { + tracing::warn!( + "x-fern-sdk-variables entry has non-string key {:?}; skipping", + name_val + ); + continue; + } + }; + let schema_map = match schema_val.as_mapping() { + Some(m) => m, + None => { + tracing::warn!( + "x-fern-sdk-variables entry '{name}' is not an object; skipping" + ); + continue; + } + }; + let ty = schema_map + .get(serde_yaml::Value::String("type".into())) + .and_then(|v| v.as_str()) + .unwrap_or("string") + .to_string(); + if ty != "string" { + tracing::warn!( + "x-fern-sdk-variables entry '{name}' has unsupported type '{ty}'; \ + only string variables are supported today (skipping)" + ); + continue; + } + let description = schema_map + .get(serde_yaml::Value::String("description".into())) + .and_then(|v| v.as_str()) + .map(str::to_string); + out.push(SdkVariable { + name, + ty, + description, + }); + } + out +} + +// --------------------------------------------------------------------------- +// Parameter conversion +// --------------------------------------------------------------------------- + +fn convert_parameter( + param: &OpenApiParameter, + ref_site_default: Option<&serde_yaml::Value>, +) -> (String, MethodParameter) { + let (param_type, enum_values, schema_default, format, fern_enum) = match ¶m.schema { + Some(s) => ( + s.schema_type.clone(), + s.enum_values.clone(), + s.default.as_ref(), + s.format.clone(), + convert_fern_enum(s.x_fern_enum.as_ref()), + ), + None => (None, None, None, None, None), + }; + + // `x-fern-default` is the only source of a client-side default — + // i.e. a value the CLI will (a) advertise in `--help` via clap's + // `[default: ...]` and (b) substitute into the outgoing request + // when the user omits the flag. Within the extension, ref-site wins + // over the resolved component parameter, mirroring fern's + // openapi-ir-parser precedence: + // getExtension(parameter, FERN_DEFAULT) + // ?? getExtension(resolvedParameter, FERN_DEFAULT) + let client_yaml_default: Option<&serde_yaml::Value> = + ref_site_default.or(param.x_fern_default.as_ref()); + let default_value = client_yaml_default.and_then(yaml_value_to_json); + + // The OpenAPI standard `default:` keyword on a parameter's schema + // describes server-side behavior — it tells the client what the API + // will do if the value is omitted, not what the client should send. + // We surface it in `--help` as a documentation hint only. + // + // When `x-fern-default` is present it supersedes the documentation + // hint for display too (showing two different defaults would confuse + // users), so we drop the schema default in that case. + let documentation_default_value = if default_value.is_some() { + None + } else { + schema_default.and_then(yaml_value_to_json) + }; + + // Operation-level `x-fern-availability` wins; otherwise fall back to + // OpenAPI's standard `deprecated: true` flag so flags marked deprecated + // in the source spec still surface a `[DEPRECATED]` badge in `--help`. + let availability = match param.x_fern_availability { + Some(a) => Some(a), + None if param.deprecated => Some(Availability::Deprecated), + None => None, + }; + + // `x-fern-sdk-variable` is only honored on `in: path` parameters — + // Fern's IR drops references on query/header/cookie params with a + // log line, and so do we (the parameter still surfaces as a normal + // per-op flag). + let variable_reference = match param.x_fern_sdk_variable.as_deref() { + Some(name) if param.location.as_deref() == Some("path") => Some(name.to_string()), + Some(name) => { + tracing::warn!( + "x-fern-sdk-variable '{name}' on non-path parameter '{}' is ignored", + param.name + ); + None + } + None => None, + }; + + let mp = MethodParameter { + param_type, + description: param.description.clone(), + location: param.location.clone(), + required: param.required, + format, + default_value, + documentation_default_value, + enum_values, + style: param.style.clone(), + explode: param.explode, + deprecated: param.deprecated, + availability, + fern_enum, + variable_reference, + ..Default::default() + }; + + (param.name.clone(), mp) +} + +/// Lower the raw YAML `x-fern-enum` map into the internal representation. +/// Drops entries whose `name` and `description` are both empty/whitespace +/// so downstream clap rendering doesn't emit blank labels or help text. +/// Returns `None` if the extension is absent or every entry was empty — +/// `None` is the signal cli-sdk uses to mean "fall back to wire values". +fn convert_fern_enum( + raw: Option<&HashMap>, +) -> Option> { + let raw = raw?; + let normalize = |s: &Option| -> Option { + s.as_ref().and_then(|v| { + let t = v.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } + }) + }; + let mut out: HashMap = HashMap::new(); + for (wire, entry) in raw { + let display_name = normalize(&entry.name); + let description = normalize(&entry.description); + if display_name.is_none() && description.is_none() { + continue; + } + out.insert( + wire.clone(), + crate::openapi::discovery::FernEnumValue { + display_name, + description, + }, + ); + } + if out.is_empty() { None } else { Some(out) } +} + +/// Convert a `serde_yaml::Value` into a `serde_json::Value` for storage on +/// `MethodParameter::default_value` (from `x-fern-default`) and +/// `MethodParameter::documentation_default_value` (from the standard +/// OpenAPI `default:` keyword). Mirrors YAML's scalar coverage so a +/// `100` keeps its integer type, `true` keeps its boolean type, and +/// `"abc"` stays a string. Tagged values are unwrapped; `~`/`null` +/// collapses to `Value::Null`. +fn yaml_value_to_json(v: &serde_yaml::Value) -> Option { + match v { + serde_yaml::Value::Null => Some(serde_json::Value::Null), + serde_yaml::Value::Bool(b) => Some(serde_json::Value::Bool(*b)), + serde_yaml::Value::Number(n) => { + if let Some(u) = n.as_u64() { + Some(serde_json::Value::Number(u.into())) + } else if let Some(i) = n.as_i64() { + Some(serde_json::Value::Number(i.into())) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f).map(serde_json::Value::Number) + } else { + None + } + } + serde_yaml::Value::String(s) => Some(serde_json::Value::String(s.clone())), + serde_yaml::Value::Sequence(seq) => Some(serde_json::Value::Array( + seq.iter().filter_map(yaml_value_to_json).collect(), + )), + serde_yaml::Value::Mapping(map) => { + let mut obj = serde_json::Map::new(); + for (k, val) in map { + let key = match k { + serde_yaml::Value::String(s) => s.clone(), + other => serde_yaml::to_string(other).ok()?.trim().to_string(), + }; + if let Some(jv) = yaml_value_to_json(val) { + obj.insert(key, jv); + } + } + Some(serde_json::Value::Object(obj)) + } + serde_yaml::Value::Tagged(t) => yaml_value_to_json(&t.value), + } +} + +fn resolve_parameter<'a>( + por: &'a OpenApiParamOrRef, + components: &'a Option, +) -> Option<&'a OpenApiParameter> { + match por { + OpenApiParamOrRef::Inline(p) => Some(p.as_ref()), + OpenApiParamOrRef::Ref { ref_path, .. } => { + let name = strip_ref_prefix(ref_path); + components + .as_ref() + .and_then(|c| c.parameters.get(&name)) + } + } +} + +/// Resolve the effective `x-fern-parameter-name` for a parameter using +/// the same precedence as `x-fern-ignore`: a value placed at the +/// **ref-site** object (alongside `$ref`) wins over the value on the +/// **resolved component parameter**. Inline parameters short-circuit to +/// their own value. Returns `None` when no alias is set. +/// +/// Implements the same semantics as fern's openapi-ir-parser +/// (`getParameterName.ts` + the `??` chain used for `x-fern-ignore`): +/// ```ts +/// const alias = +/// getExtension(parameter, PARAMETER_NAME) ?? +/// getExtension(resolvedParameter, PARAMETER_NAME); +/// ``` +fn resolve_parameter_display_name( + por: &OpenApiParamOrRef, + components: &Option, +) -> Option { + match por { + OpenApiParamOrRef::Inline(p) => p.x_fern_parameter_name.clone(), + OpenApiParamOrRef::Ref { + x_fern_parameter_name: ref_site, + .. + } => { + let resolved = resolve_parameter(por, components) + .and_then(|p| p.x_fern_parameter_name.clone()); + ref_site.clone().or(resolved) + } + } +} + +/// Resolve the effective `x-fern-ignore` value for a parameter, mirroring +/// fern's precedence: a value on the **ref-site object** (placed next to +/// `$ref`) wins over the value on the **resolved component parameter**. +/// Inline parameters are a single site, so they short-circuit. Returns +/// `false` when no flag is set at any level. +/// +/// Implements the same semantics as fern's openapi-ir-parser: +/// ```ts +/// const shouldIgnore = +/// getExtension(parameter, IGNORE) ?? +/// getExtension(resolvedParameter, IGNORE); +/// ``` +fn parameter_should_ignore( + por: &OpenApiParamOrRef, + components: &Option, +) -> bool { + match por { + OpenApiParamOrRef::Inline(p) => p.x_fern_ignore.unwrap_or(false), + OpenApiParamOrRef::Ref { + x_fern_ignore: ref_site, + .. + } => { + let resolved = resolve_parameter(por, components).and_then(|p| p.x_fern_ignore); + ref_site.or(resolved).unwrap_or(false) + } + } +} + +// --------------------------------------------------------------------------- +// Core conversion +// --------------------------------------------------------------------------- + +/// Load and convert an OpenAPI 3.0 YAML spec into the internal `RestDescription`. +pub fn load_openapi_spec(yaml_str: &str, cli_name: &str) -> Result { + let value: serde_yaml::Value = serde_yaml::from_str(yaml_str) + .map_err(|e| CliError::Discovery(format!("Failed to parse OpenAPI spec: {e}")))?; + load_openapi_spec_from_value(value, cli_name) +} + +/// Load and convert an OpenAPI spec from a pre-parsed `serde_yaml::Value`. +/// +/// This is the workhorse behind both [`load_openapi_spec`] (plain string) and +/// the overrides path where a base spec and override YAML are deep-merged into +/// a single `Value` before deserialization. +pub fn load_openapi_spec_from_value( + value: serde_yaml::Value, + cli_name: &str, +) -> Result { + let spec: OpenApiSpec = serde_yaml::from_value(value) + .map_err(|e| CliError::Discovery(format!("Failed to parse OpenAPI spec: {e}")))?; + + let root_url = spec + .servers + .first() + .map(|s| s.url.clone()) + .unwrap_or_default(); + + // Lower the spec's top-level `servers:` array into the internal + // representation. Order is preserved so callers can rely on + // "first server is the default" — the same rule that + // populates `root_url` above. + let top_level_servers: Vec = spec + .servers + .iter() + .map(OpenApiServer::to_discovery_server) + .collect(); + + // Convert component schemas. + // + // TODO(FER-9864): mirror fern's component-schema + property-level + // `x-fern-ignore` here once body fields surface as CLI flags. Fern's + // openapi-ir-parser drops ignored schemas in `convertSchemas.ts` and + // ignored properties in `convertObject.ts`; the CLI today only exposes + // operations + parameters, so those levels are a no-op for now and + // intentionally left unhandled. + let schemas: HashMap = spec + .components + .as_ref() + .map(|c| { + c.schemas + .iter() + .map(|(name, obj)| (name.clone(), convert_schema_object(obj))) + .collect() + }) + .unwrap_or_default(); + + // OpenAPI 3.1 `webhooks` describe inbound operations (server → user), + // so we capture them at parse time but do not lower them into CLI + // subcommands. A non-empty block is surfaced at debug level so users + // can see why a spec with only webhooks produces no commands. + if !spec.webhooks.is_empty() { + tracing::debug!( + "Spec declares {} webhook(s); webhooks are inbound and not lowered to CLI subcommands.", + spec.webhooks.len(), + ); + } + + // Lower components.securitySchemes to discovery types + let security_schemes: HashMap = spec + .components + .as_ref() + .map(|c| { + c.security_schemes + .iter() + .map(|(name, raw)| (name.clone(), lower_security_scheme(raw))) + .collect() + }) + .unwrap_or_default(); + + // Detect pagination token parameter name from components/parameters + let (pagination_query_param, pagination_response_path) = detect_pagination_config(&spec); + + // Normalize `x-fern-base-path`: trim ASCII whitespace and treat an empty + // string as absent so downstream slash-joining doesn't have to worry about + // a degenerate "" case. Leading/trailing slashes are preserved here — + // `build_url` is what normalizes them into exactly one slash between + // segments, so we don't lose authoring intent at parse time. + let base_path = normalize_base_path(spec.x_fern_base_path.as_deref()); + + // Lower spec-root `x-fern-idempotency-headers` into discovery types. Each + // entry will be materialized as a CLI flag on every idempotent operation + // below; non-idempotent operations never see these headers. + let idempotency_headers: Vec = spec + .x_fern_idempotency_headers + .as_ref() + .map(|raws| { + raws.iter() + .map(|raw| IdempotencyHeader { + header: raw.header.clone(), + name: raw.name.clone(), + env: raw.env.clone(), + }) + .collect() + }) + .unwrap_or_default(); + + // Lower the spec-root `x-fern-sdk-variables` block once. Variables + // surface as global flags later in `CliApp::run_async`; storing them + // on `RestDescription` keeps the parser as the single source of + // truth for both flag registration and per-operation substitution. + let sdk_variables = parse_sdk_variables(spec.x_fern_sdk_variables.as_ref()); + + // Spec-root `x-fern-retries`. Operations inherit this block when they + // either omit `x-fern-retries` or set it to `true`. Parsed once here + // so per-op resolution stays a cheap merge. + let spec_root_retries = parse_retries_value( + spec.x_fern_retries.as_ref().unwrap_or(&serde_yaml::Value::Null), + /*op_id=*/ "", + /*inherited=*/ true, + )?; + + // Lower the spec-root `x-fern-global-headers` block once. Globals + // surface as root flags in `CliApp::run_async` and are stamped on + // every outgoing request by the executor (per-operation parameters + // with the same wire-name still win). + let global_headers: Vec = spec + .x_fern_global_headers + .as_ref() + .map(|raws| lower_global_headers(raws)) + .unwrap_or_default(); + + // Lower the document-root `x-fern-groups` extension. Keys are + // kebab-cased so they match the resource-tree keys built from + // `x-fern-sdk-group-name` further down. Mirrors fern's + // `XFernGroupsSchema` (record of `{ summary?, description? }`). + let groups: HashMap = spec + .x_fern_groups + .as_ref() + .map(lower_fern_groups) + .unwrap_or_default(); + + let mut doc = RestDescription { + name: cli_name.to_string(), + version: spec.info.version.clone(), + title: spec.info.title.clone(), + description: spec.info.description.clone(), + root_url: root_url.clone(), + servers: top_level_servers, + service_path: String::new(), + base_path, + schemas, + security_schemes, + pagination_token_query_param: pagination_query_param, + pagination_token_response_path: pagination_response_path, + idempotency_headers, + sdk_variables, + retries: spec_root_retries.clone(), + global_headers, + groups, + ..Default::default() + }; + + // Spec-level security default. Inherited by every operation that + // doesn't declare its own `security:` block. An operation's + // `security: []` (explicit empty) overrides the default with anonymous. + let spec_default_security = spec.security.clone(); + + // Spec-root `x-fern-pagination`. Per-op `x-fern-pagination: true` + // inherits this block; per-op missing-or-`false` ignores it. + let spec_root_pagination = spec.x_fern_pagination.clone(); + + // Spec-root `x-fern-retries`. Per-op `x-fern-retries: true` adopts + // this block; per-op `false` or `{ disabled: true }` overrides it; + // per-op object merges over it field-by-field. + let spec_root_retries_raw = spec.x_fern_retries.clone(); + + // Build a reference to the component schemas for $ref body resolution. + let empty_component_schemas: HashMap = HashMap::new(); + let component_schemas: &HashMap = spec + .components + .as_ref() + .map(|c| &c.schemas) + .unwrap_or(&empty_component_schemas); + + // Process each path + method + #[allow(clippy::type_complexity)] + let http_methods: &[(&str, fn(&OpenApiPathItem) -> &Option)] = &[ + ("GET", |p: &OpenApiPathItem| &p.get), + ("POST", |p: &OpenApiPathItem| &p.post), + ("PUT", |p: &OpenApiPathItem| &p.put), + ("PATCH", |p: &OpenApiPathItem| &p.patch), + ("DELETE", |p: &OpenApiPathItem| &p.delete), + ]; + + for (path, path_item) in &spec.paths { + for &(http_method, accessor) in http_methods { + let operation = match accessor(path_item) { + Some(op) => op, + None => continue, + }; + + // Fern parity: `x-fern-ignore: true` drops the operation from the + // generated CLI surface entirely. The operation does not appear + // as a subcommand, in `--help`, or in completions. Log message + // mirrors fern's openapi-ir-parser wording so the two systems + // produce consistent diagnostics. + if operation.x_fern_ignore.unwrap_or(false) { + tracing::debug!( + "{} {} is marked with x-fern-ignore. Skipping.", + http_method, + path + ); + continue; + } + + // Resolve group name: prefer x-fern-sdk-group-name, fall back to first tag + let fern_group; + let tag_group; + let group_name: &Vec = match &operation.x_fern_sdk_group_name { + Some(g) if !g.is_empty() => g, + _ => match operation.tags.as_ref().and_then(|t| t.first()) { + Some(tag) => { + tag_group = vec![tag.clone()]; + &tag_group + } + None => { + // Fall back to first path segment as group + let segment = path + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("default") + .to_string(); + fern_group = vec![segment]; + &fern_group + } + }, + }; + + // Resolve method name: prefer x-fern-sdk-method-name, fall back to operationId or http+path. + // When the group came from a tag (no x-fern-sdk-group-name), strip + // tag tokens that prefix the operationId so e.g. `Customers` tag + // + `customersList` operation → method `list` rather than + // `customers-list`. Mirrors Fern's OpenAPI importer. + let method_name = match &operation.x_fern_sdk_method_name { + Some(m) => m.clone(), + None => match &operation.operation_id { + Some(id) => { + let stripped = if operation.x_fern_sdk_group_name.is_none() { + match operation.tags.as_ref().and_then(|t| t.first()) { + Some(tag) => strip_tag_prefix(id, tag), + None => id.clone(), + } + } else { + id.clone() + }; + camel_to_kebab(&stripped) + } + None => format!( + "{}-{}", + http_method.to_lowercase(), + path.trim_start_matches('/').replace('/', "-") + ), + }, + }; + + // Collect parameters (path-level + operation-level). Parameters + // marked `x-fern-ignore: true` are dropped — they don't surface + // as CLI flags and aren't sent in the outgoing request. + // + // The flag is read with fern's precedence: a value placed at + // the **ref-site** object (alongside `$ref`) wins over the + // value on the resolved component parameter. This matches + // OpenAPI 3.1's allowance of sibling fields next to `$ref` and + // fern's overlay system, which routinely uses ref-site ignores. + let mut params = HashMap::new(); + for por in path_item.parameters.iter().chain(operation.parameters.iter()) { + if parameter_should_ignore(por, &spec.components) { + tracing::debug!( + "{} {} has a parameter marked with x-fern-ignore. Skipping.", + http_method, + path + ); + continue; + } + let display_name = resolve_parameter_display_name(por, &spec.components); + if let Some(p) = resolve_parameter(por, &spec.components) { + // Ref-site `x-fern-default` (placed alongside `$ref`) wins + // over the value on the resolved component parameter — + // mirrors fern's importer precedence for `getExtension`. + let ref_site_default = match por { + OpenApiParamOrRef::Ref { x_fern_default, .. } => x_fern_default.as_ref(), + OpenApiParamOrRef::Inline(_) => None, + }; + let (name, mut mp) = convert_parameter(p, ref_site_default); + mp.display_name = display_name; + params.insert(name, mp); + } + } + + // Handle request body — also harvests body-located parameters so + // the command builder can render per-field flags alongside `--json`. + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( + &operation.request_body, + operation.operation_id.as_deref().unwrap_or("unknown"), + &mut doc.schemas, + component_schemas, + ); + // Skip body fields whose names collide with existing path/query/header + // params — those win, since the spec's `parameters` array is the + // canonical source for non-body inputs. + for (name, param) in body_params { + params.entry(name).or_insert(param); + } + + let description = operation + .summary + .clone() + .or_else(|| operation.description.clone()); + + let method_root_url = operation.servers + .first() + .map(|s| s.url.clone()) + .unwrap_or_else(|| root_url.clone()); + + // Per-op `servers:` overrides replace the global default for + // this operation. Lower them into the internal representation + // so the executor can route the global `--server ` flag + // against per-op named entries before falling back to + // `method_root_url` (the first per-op server). + let method_servers: Vec = operation + .servers + .iter() + .map(OpenApiServer::to_discovery_server) + .collect(); + + // OpenAPI inheritance: operation-level `security` (including an + // explicit empty array) takes precedence; otherwise inherit the + // spec-level default; if neither is present the operation has no + // declared policy. + let security_requirements = match &operation.security { + Some(reqs) => Some(reqs.clone()), + None => spec_default_security.clone(), + }; + + let pagination = resolve_pagination_extension( + operation.x_fern_pagination.as_ref(), + spec_root_pagination.as_ref(), + operation.operation_id.as_deref().unwrap_or("unknown"), + )?; + + let retries = resolve_retries_extension( + operation.x_fern_retries.as_ref(), + spec_root_retries_raw.as_ref(), + operation.operation_id.as_deref().unwrap_or("unknown"), + )?; + + // `x-fern-availability` wins; otherwise fall back to OpenAPI's + // standard `deprecated: true` flag so deprecated ops still get + // a `[DEPRECATED]` badge without requiring the extension. + let availability = match operation.x_fern_availability { + Some(a) => Some(a), + None if operation.deprecated => Some(Availability::Deprecated), + None => None, + }; + + let idempotent = operation.x_fern_idempotent.unwrap_or(false); + + // `x-fern-audiences` is an array of strings; missing means + // `[]`. Stored verbatim so the command-tree filter can + // mirror fern's `some(...)` membership check exactly. See + // discovery.rs `RestMethod::audiences` for the rationale on + // why this is parser-recorded but only consumed at the + // command-tree layer. + let audiences = operation.x_fern_audiences.clone().unwrap_or_default(); + + // Materialize idempotency-header flags on idempotent operations + // ONLY. Each spec-root `x-fern-idempotency-headers` entry becomes + // a synthetic header MethodParameter so the existing + // header-parameter pathway (clap flag → executor request + // header) handles the value. Non-idempotent siblings get no + // such parameter and therefore never send these headers on the + // wire, even if the user passes the flag explicitly (clap + // rejects it as unknown). + if idempotent { + inject_idempotency_header_params(&mut params, &doc.idempotency_headers); + } + + let return_value = operation + .x_fern_sdk_return_value + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let streaming = parse_streaming_extension( + operation.x_fern_streaming.as_ref(), + operation.operation_id.as_deref().unwrap_or("unknown"), + )?; + + // Mutual exclusivity: an operation that's both streamed and + // paginated is incoherent — pagination drives a loop of + // requests against fully-buffered responses, while + // streaming consumes a single open response incrementally. + // The upstream Fern IR doesn't generate a meaningful + // combination either; mirror that by failing at parse time + // so spec authors get a single clear error instead of an + // ambiguous runtime fallback. + if streaming.is_some() && pagination.is_some() { + return Err(CliError::Discovery(format!( + "Operation '{}' declares both `x-fern-streaming` and \ + `x-fern-pagination`, which are mutually exclusive. Streaming \ + operations open a single long-lived response; paginated \ + operations issue multiple requests against unary responses.", + operation.operation_id.as_deref().unwrap_or("unknown"), + ))); + } + + + let rest_method = RestMethod { + id: operation.operation_id.clone(), + description, + http_method: http_method.to_string(), + path: path.clone(), + parameters: params, + request, + root_url: method_root_url, + servers: method_servers, + binary_request_body, + body_encoding, + security_requirements, + pagination, + availability, + idempotent, + return_value, + streaming, + retries, + audiences, + ..Default::default() + }; + + // Walk group_name to create/find nested resources + let kebab_groups: Vec = + group_name.iter().map(|g| camel_to_kebab(g)).collect(); + + insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + } + } + + // Fern parity: if every operation under a path/group was ignored, prune + // the now-empty group so it doesn't appear as a subcommand with no + // leaves in `--help` or completions. + prune_empty_resources(&mut doc.resources); + + Ok(doc) +} + +/// Recursively drop resources that contain no methods and no non-empty +/// nested resources. Called after all paths have been processed so that +/// `x-fern-ignore`-only paths don't leave orphan groups in the command tree. +fn prune_empty_resources(resources: &mut HashMap) { + resources.retain(|_, resource| { + prune_empty_resources(&mut resource.resources); + !resource.methods.is_empty() || !resource.resources.is_empty() + }); +} + +/// Walk the group name list to find or create nested resources and insert the method. +fn insert_method_into_resources( + resources: &mut HashMap, + groups: &[String], + method_name: &str, + method: RestMethod, +) { + if groups.is_empty() { + return; + } + + let resource = resources + .entry(groups[0].clone()) + .or_default(); + + if groups.len() == 1 { + resource.methods.insert(method_name.to_string(), method); + } else { + insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + } +} + +/// Extract request body info from an OpenAPI requestBody. +/// +/// Maximum recursion depth for flattening nested request body object properties +/// into dot-notation flags. Mirrors `MAX_INPUT_DEPTH` in `graphql/parser.rs`. +/// Properties at depth >= MAX_BODY_DEPTH are not flattened — `--json` remains +/// the only way to supply them. +const MAX_BODY_DEPTH: u8 = 3; + +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: +/// - `json_schema`: a SchemaRef for the JSON request body (if `application/json` is declared). +/// - `binary_body`: metadata when the operation expects a raw binary body +/// (any non-JSON / non-form media type). +/// - `body_encoding`: how the request body should be serialized on the wire. +/// - `body_params`: per-field flag map; when the body is an inline object schema, +/// each property up to MAX_BODY_DEPTH is exposed as a body-located [`MethodParameter`] +/// with dotted keys for nested fields. `$ref` bodies are resolved from +/// `component_schemas` and their properties flattened with the same depth rules. +fn extract_request_body( + request_body: &Option, + operation_id: &str, + schemas: &mut HashMap, + component_schemas: &HashMap, +) -> (Option, Option, BodyEncoding, HashMap) { + let Some(body) = request_body.as_ref() else { + return (None, None, BodyEncoding::Json, HashMap::new()); + }; + let Some(content) = body.content.as_ref() else { + return (None, None, BodyEncoding::Json, HashMap::new()); + }; + + if let Some(media) = content.get("application/json") { + if let Some(schema_obj) = media.schema.as_ref() { + if let Some(ref_path) = &schema_obj.schema_ref { + let name = strip_ref_prefix(ref_path); + // Resolve the $ref from components/schemas and flatten its properties. + let body_params = component_schemas + .get(&name) + .map(|resolved| flatten_body_params(resolved, component_schemas, 0)) + .unwrap_or_default(); + return ( + Some(SchemaRef { + schema_ref: Some(name), + ..Default::default() + }), + None, + BodyEncoding::Json, + body_params, + ); + } + + let body_params = flatten_body_params(schema_obj, component_schemas, 0); + + let synthetic_name = format!("{operation_id}_request"); + let converted = convert_schema_object(schema_obj); + schemas.insert(synthetic_name.clone(), converted); + + return ( + Some(SchemaRef { + schema_ref: Some(synthetic_name), + ..Default::default() + }), + None, + BodyEncoding::Json, + body_params, + ); + } + } + + // No JSON body declared — check for form-urlencoded body next. + if let Some(media) = content.get("application/x-www-form-urlencoded") { + if let Some(schema_obj) = media.schema.as_ref() { + if let Some(ref_path) = &schema_obj.schema_ref { + let name = strip_ref_prefix(ref_path); + let body_params = component_schemas + .get(&name) + .map(|resolved| flatten_body_params(resolved, component_schemas, 0)) + .unwrap_or_default(); + return ( + Some(SchemaRef { + schema_ref: Some(name), + ..Default::default() + }), + None, + BodyEncoding::FormUrlEncoded, + body_params, + ); + } + + let body_params = flatten_body_params(schema_obj, component_schemas, 0); + + let synthetic_name = format!("{operation_id}_request"); + let converted = convert_schema_object(schema_obj); + schemas.insert(synthetic_name.clone(), converted); + + return ( + Some(SchemaRef { + schema_ref: Some(synthetic_name), + ..Default::default() + }), + None, + BodyEncoding::FormUrlEncoded, + body_params, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). + let Some((content_type, media)) = content.iter().find(|(ct, _)| { + let ct = ct.as_str(); + ct != "application/x-www-form-urlencoded" && ct != "multipart/form-data" + }) else { + return (None, None, BodyEncoding::Json, HashMap::new()); + }; + + let is_binary_format = media + .schema + .as_ref() + .and_then(|s| s.format.as_deref()) + .map(|f| f == "binary") + .unwrap_or(false); + + let flag_name = body + .x_fern_parameter_name + .as_deref() + .map(camel_to_kebab) + .unwrap_or_else(|| { + if is_binary_format { + "file".to_string() + } else { + "body".to_string() + } + }); + + ( + None, + Some(BinaryRequestBody { + content_type: content_type.clone(), + flag_name, + }), + BodyEncoding::Json, + HashMap::new(), + ) +} + +/// Recursively walk an object schema and emit one body-located [`MethodParameter`] +/// per property, up to `MAX_BODY_DEPTH` levels deep. Nested object properties +/// use dotted keys (e.g. `"name.first"`). Array properties set `repeated: true` +/// so the command builder renders `ArgAction::Append`. Read-only properties are +/// skipped. Non-object schemas at the root return an empty map. +fn flatten_body_params( + schema: &OpenApiSchemaObject, + component_schemas: &HashMap, + depth: u8, +) -> HashMap { + flatten_body_params_prefix(schema, component_schemas, depth, "") +} + +fn flatten_body_params_prefix( + schema: &OpenApiSchemaObject, + component_schemas: &HashMap, + depth: u8, + prefix: &str, +) -> HashMap { + let mut out = HashMap::new(); + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { + return out; + } + let required: std::collections::HashSet<&str> = + schema.required.iter().map(String::as_str).collect(); + for (name, prop) in &schema.properties { + if prop.read_only { + continue; + } + let full_key = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + + // $ref property: resolve from component_schemas before checking type. + if let Some(ref_path) = &prop.schema_ref { + let ref_name = strip_ref_prefix(ref_path); + if let Some(resolved) = component_schemas.get(&ref_name) { + if resolved.schema_type() == Some("object") { + let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); + if !nested.is_empty() { + out.extend(nested); + continue; + } + } + // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. + let is_array = resolved.schema_type() == Some("array"); + let const_default = const_default_value(resolved); + out.insert( + full_key, + MethodParameter { + param_type: if is_array { + Some("string".to_string()) + } else { + resolved.schema_type().map(str::to_string) + }, + description: prop.description.clone().or_else(|| resolved.description.clone()), + location: Some("body".to_string()), + // A `const` makes the field effectively optional: the + // value is fixed, so we auto-inject it via default_value + // when omitted. Spec's `required:` only matters when the + // user could meaningfully choose to omit a value. + required: required.contains(name.as_str()) && const_default.is_none(), + format: resolved.format.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, + repeated: is_array, + ..Default::default() + }, + ); + } + // Unresolvable $ref — skip rather than emitting a typeless flag. + continue; + } + + let prop_type = prop.schema_type(); + + // Nested object: recurse to emit dot-notation flags. If nothing comes + // back (no sub-properties or depth limit hit), fall through to the default insert below. + if prop_type == Some("object") { + let nested = flatten_body_params_prefix(prop, component_schemas, depth + 1, &full_key); + if !nested.is_empty() { + out.extend(nested); + continue; + } + } + + let is_array = prop_type == Some("array"); + let const_default = const_default_value(prop); + out.insert( + full_key, + MethodParameter { + param_type: if is_array { + Some("string".to_string()) + } else { + prop_type.map(str::to_string) + }, + description: prop.description.clone(), + location: Some("body".to_string()), + required: required.contains(name.as_str()) && const_default.is_none(), + format: prop.format.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, + repeated: is_array, + ..Default::default() + }, + ); + } + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + + #[test] + fn test_camel_to_kebab() { + assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); + assert_eq!(camel_to_kebab("eventTypes"), "event-types"); + assert_eq!(camel_to_kebab("users"), "users"); + assert_eq!(camel_to_kebab("dataCompliance"), "data-compliance"); + assert_eq!(camel_to_kebab("ABC"), "a-b-c"); + // Tags from OpenAPI specs often contain spaces or hyphens — these + // should collapse to a single hyphen, not preserve a space before + // the next word's leading character. + assert_eq!(camel_to_kebab("Channel Settings"), "channel-settings"); + assert_eq!(camel_to_kebab("Attribute Values"), "attribute-values"); + assert_eq!(camel_to_kebab("Metafields Batch"), "metafields-batch"); + assert_eq!(camel_to_kebab("foo--bar"), "foo-bar"); + assert_eq!(camel_to_kebab("CustomerList"), "customer-list"); + } + + /// Locks `build.rs::to_kebab` and `parser.rs::camel_to_kebab` to the + /// same output. They must be byte-for-byte equivalent so the smoke-test + /// constants emitted by build.rs match what the parser produces at + /// runtime. If this test fails after a build.rs edit, sync the two impls. + #[test] + fn test_build_rs_to_kebab_matches_parser_camel_to_kebab() { + // Inline copy of build.rs::to_kebab — drift here is the whole point + // of the test, so we can't just call it. + fn build_rs_to_kebab(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for ch in s.chars() { + if !ch.is_ascii_alphanumeric() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } else if ch.is_uppercase() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + while result.ends_with('-') { + result.pop(); + } + result + } + for case in [ + "scheduledEvents", + "Metadata taxonomies", // hit the bug that started this + "Channel Settings", + "foo--bar", + "CustomerList", + "ABC", + "with.dot.separators", + "trailing---dashes-", + "leading---dashes", + "_leading_underscore", + ] { + assert_eq!( + build_rs_to_kebab(case), + camel_to_kebab(case), + "drift between build.rs::to_kebab and parser::camel_to_kebab for input {case:?}" + ); + } + } + + #[test] + fn test_tokenize_camel_and_other() { + // camelCase: split on capitals + assert_eq!(tokenize("getCustomers"), vec!["get", "customers"]); + assert_eq!(tokenize("customersList"), vec!["customers", "list"]); + // snake_case / spaces / mixed: split on non-alphanumeric + assert_eq!(tokenize("customer_addresses"), vec!["customer", "addresses"]); + assert_eq!(tokenize("Customer Addresses"), vec!["customer", "addresses"]); + // already a single token + assert_eq!(tokenize("customers"), vec!["customers"]); + } + + #[test] + fn test_strip_tag_prefix_strips_when_op_starts_with_tag() { + // Fern parity: `Customers` tag + `customersList` operationId → `list`. + assert_eq!(strip_tag_prefix("customersList", "Customers"), "list"); + // Multi-token tag ("Customer Addresses") matches multi-token op prefix. + assert_eq!( + strip_tag_prefix("customerAddressesList", "Customer Addresses"), + "list" + ); + } + + #[test] + fn test_strip_tag_prefix_no_strip_when_no_overlap() { + // When op `getCustomers` doesn't start with tag tokens. + assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); + } + + #[test] + fn test_method_name_strips_tag_prefix_with_tag_grouping() { + // Tag-driven group + operationId starts with tag → method = remainder. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: customersList + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!(customers.methods.contains_key("list"), "method should be `list` after strip"); + } + + #[test] + fn test_method_name_keeps_operation_id_when_no_tag_overlap() { + // When operationId doesn't start with tag → method + // stays as full kebab'd operationId. Matches Fern's behavior. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!(customers.methods.contains_key("get-customers")); + } + + #[test] + fn test_binary_request_body_flag_name_defaults_to_file_for_format_binary() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /upload: + post: + x-fern-sdk-group-name: files + x-fern-sdk-method-name: upload + operationId: uploadFile + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let upload = &doc.resources["files"].methods["upload"]; + let binary = upload.binary_request_body.as_ref().unwrap(); + assert_eq!(binary.content_type, "application/octet-stream"); + assert_eq!(binary.flag_name, "file"); + } + + #[test] + fn test_binary_request_body_honors_x_fern_parameter_name() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /audio: + post: + x-fern-sdk-group-name: audio + x-fern-sdk-method-name: send + operationId: sendAudio + requestBody: + x-fern-parameter-name: audioFile + content: + audio/mpeg: + schema: + type: string + format: binary + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let send = &doc.resources["audio"].methods["send"]; + let binary = send.binary_request_body.as_ref().unwrap(); + assert_eq!(binary.content_type, "audio/mpeg"); + assert_eq!(binary.flag_name, "audio-file"); + } + + #[test] + fn test_binary_request_body_defaults_to_body_when_not_binary_format() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /text: + post: + x-fern-sdk-group-name: text + x-fern-sdk-method-name: send + operationId: sendText + requestBody: + content: + text/plain: + schema: + type: string + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let send = &doc.resources["text"].methods["send"]; + let binary = send.binary_request_body.as_ref().unwrap(); + assert_eq!(binary.content_type, "text/plain"); + assert_eq!(binary.flag_name, "body"); + } + + #[test] + fn test_group_name_accepts_scalar_string() { + // Some Fern specs write `x-fern-sdk-group-name: transcripts` + // as a bare string; the parser should accept it as a single-element list. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /transcripts: + get: + x-fern-sdk-group-name: transcripts + x-fern-sdk-method-name: list + operationId: listTranscripts + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert!(doc.resources.contains_key("transcripts")); + assert!(doc.resources["transcripts"].methods.contains_key("list")); + } + + #[test] + fn test_method_name_skips_strip_when_explicit_group_name() { + // x-fern-sdk-group-name is the source of truth; tag-driven strip is + // bypassed so the operationId surfaces verbatim. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /customers: + get: + tags: [Customers] + x-fern-sdk-group-name: ["customers"] + operationId: customersList + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!( + customers.methods.contains_key("customers-list"), + "explicit group-name disables tag-prefix strip" + ); + } + + #[test] + fn test_nested_group_names() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /parent/{id}/child: + get: + operationId: get-child + summary: Get a child resource + x-fern-sdk-group-name: + - parent + - child + x-fern-sdk-method-name: get-child + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.resources.contains_key("parent")); + let parent = &doc.resources["parent"]; + assert!(parent.methods.is_empty()); + assert!(parent.resources.contains_key("child")); + let child = &parent.resources["child"]; + assert!(child.methods.contains_key("get-child")); + } + + // ----------------------------------------------------------------- + // x-fern-ignore — operation-level + parameter-level + // ----------------------------------------------------------------- + + #[test] + fn test_x_fern_ignore_drops_operation() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + x-fern-ignore: true + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!( + !doc.resources.contains_key("users"), + "ignored operation's group should be pruned when no other ops remain" + ); + } + + #[test] + fn test_x_fern_ignore_drops_parameter() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - name: keep_me + in: query + schema: + type: string + - name: drop_me + in: query + x-fern-ignore: true + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!( + list.parameters.contains_key("keep_me"), + "non-ignored param should survive" + ); + assert!( + !list.parameters.contains_key("drop_me"), + "ignored param should be absent from operation" + ); + } + + #[test] + fn test_x_fern_ignore_mixed_path_keeps_non_ignored_ops() { + // Same path, two operations: GET is ignored, POST is kept. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + x-fern-ignore: true + responses: + '200': + description: OK + post: + operationId: users-create + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: create + responses: + '201': + description: Created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let users = &doc.resources["users"]; + assert!(!users.methods.contains_key("list"), "ignored op absent"); + assert!(users.methods.contains_key("create"), "non-ignored op kept"); + } + + #[test] + fn test_x_fern_ignore_prunes_empty_nested_group() { + // A nested group whose only leaf is ignored should be pruned all the + // way up — the empty parent group must not appear as a subcommand + // with no children. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /parent/child: + get: + operationId: only-op + x-fern-sdk-group-name: ["parent", "child"] + x-fern-sdk-method-name: get + x-fern-ignore: true + responses: + '200': + description: OK + /siblings: + get: + operationId: siblings-list + x-fern-sdk-group-name: ["siblings"] + x-fern-sdk-method-name: list + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!( + !doc.resources.contains_key("parent"), + "empty parent group should be pruned after only child is ignored" + ); + assert!( + doc.resources.contains_key("siblings"), + "unrelated groups must remain" + ); + } + + #[test] + fn test_x_fern_ignore_default_false_keeps_operation_and_parameter() { + // Sanity check: omitting `x-fern-ignore` keeps the operation and + // its parameters exactly as before — no behavior change for specs + // that don't use the extension. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - name: filter + in: query + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!(list.parameters.contains_key("filter")); + } + + #[test] + fn test_x_fern_ignore_at_parameter_ref_site_drops_parameter() { + // Fern parity: when `x-fern-ignore: true` lives on the **ref-site** + // object (alongside `$ref`), the parameter is dropped even when the + // referenced component itself has no ignore flag. Mirrors fern's + // openapi-ir-parser precedence: + // getExtension(parameter, IGNORE) ?? getExtension(resolvedParameter, IGNORE) + // — ref-site wins, fallback to resolved. OpenAPI 3.1 explicitly + // allows sibling fields next to `$ref`, and fern's overlay system + // routinely places ignores at the ref site. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Filter' + x-fern-ignore: true + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: OK +components: + parameters: + Filter: + name: filter + in: query + schema: + type: string + Cursor: + name: cursor + in: query + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!( + !list.parameters.contains_key("filter"), + "ref-site x-fern-ignore should drop the parameter even when the resolved component has no flag" + ); + assert!( + list.parameters.contains_key("cursor"), + "ref to a non-ignored component should still produce a parameter" + ); + } + + #[test] + fn test_x_fern_ignore_at_component_drops_parameter_via_any_ref() { + // Mirror image of the ref-site test: when the **resolved component** + // carries the ignore flag and the ref site does not, every $ref to + // that component should drop the parameter. This is the fallback + // half of fern's `??` precedence. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Legacy' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: OK +components: + parameters: + Legacy: + name: legacy + in: query + x-fern-ignore: true + schema: + type: string + Cursor: + name: cursor + in: query + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!( + !list.parameters.contains_key("legacy"), + "component-level x-fern-ignore should drop the parameter when reached via $ref" + ); + assert!(list.parameters.contains_key("cursor")); + } + + // ----------------------------------------------------------------- + // x-fern-parameter-name — alias the CLI flag while keeping the + // original wire name on the outgoing HTTP request. Mirrors fern's + // openapi-ir-parser `parameterNameOverride` (see + // packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/endpoint/convertParameters.ts). + // ----------------------------------------------------------------- + + #[test] + fn test_x_fern_parameter_name_inline_sets_display_name() { + // Canonical Fern example: a header parameter named `X-Fern-Version` + // is renamed to `version` on the SDK / CLI surface. The map key + // stays the wire name so the executor still sends it as a header + // with the original name. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: X-Fern-Version + in: header + x-fern-parameter-name: version + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list + .parameters + .get("X-Fern-Version") + .expect("parameter should still be keyed by wire name"); + assert_eq!( + p.display_name.as_deref(), + Some("version"), + "display_name should hold the x-fern-parameter-name alias" + ); + assert_eq!(p.location.as_deref(), Some("header")); + } + + #[test] + fn test_x_fern_parameter_name_absent_leaves_display_name_none() { + // Sanity: when the extension is absent, `display_name` stays + // `None` so downstream code falls back to the wire name when + // building the CLI flag. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: filter + in: query + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list.parameters.get("filter").expect("filter param missing"); + assert!( + p.display_name.is_none(), + "missing x-fern-parameter-name should leave display_name = None" + ); + } + + #[test] + fn test_x_fern_parameter_name_at_ref_site_wins_over_component() { + // Ref-site precedence (matches the `??` chain fern uses for both + // x-fern-ignore and x-fern-parameter-name). The component-level + // alias is `legacyName`, but the ref-site override is `newName` + // — the ref-site value wins. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/LegacyParam' + x-fern-parameter-name: newName + responses: + '200': + description: OK +components: + parameters: + LegacyParam: + name: legacy_param + in: query + x-fern-parameter-name: legacyName + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list + .parameters + .get("legacy_param") + .expect("wire name (param name) should still be the map key"); + assert_eq!( + p.display_name.as_deref(), + Some("newName"), + "ref-site x-fern-parameter-name should win over the resolved component value" + ); + } + + #[test] + fn test_x_fern_parameter_name_falls_back_to_component_when_ref_site_absent() { + // The fallback half of the `??` precedence: when the ref site has + // no alias, the resolved component's `x-fern-parameter-name` is + // used. This is the common case for shared parameter components. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/SharedHeader' + responses: + '200': + description: OK +components: + parameters: + SharedHeader: + name: X-Fern-Version + in: header + x-fern-parameter-name: version + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list + .parameters + .get("X-Fern-Version") + .expect("wire name should be the map key"); + assert_eq!( + p.display_name.as_deref(), + Some("version"), + "component-level x-fern-parameter-name should be honored when ref site has none" + ); + } + + #[test] + fn test_x_fern_parameter_name_kebab_normalization_via_commands_builder() { + // The parser stores the raw alias as-is; kebab-casing is the + // command builder's responsibility (see `to_kebab_flag` in + // src/text.rs). This test pins the parser contract: the value + // stored on `MethodParameter::display_name` must match what the + // spec wrote, so the flag-builder can canonicalize it itself. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: X-Some-Wire-Header + in: header + x-fern-parameter-name: customerAccountId + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = &list.parameters["X-Some-Wire-Header"]; + // Raw value, exactly as the spec wrote it. `to_kebab_flag` + // converts `customerAccountId` → `customer-account-id`. + assert_eq!(p.display_name.as_deref(), Some("customerAccountId")); + // And the unit test for kebab normalization itself already lives + // in `src/text.rs` — see `test_to_kebab_flag`. + assert_eq!( + crate::text::to_kebab_flag(p.display_name.as_deref().unwrap()), + "customer-account-id" + ); + } + + // ----------------------------------------------------------------- + // x-fern-default vs. OpenAPI standard `default:` + // + // We split the two sources because they mean different things: + // * `x-fern-default` is a CLIENT-SIDE default — the CLI sends it + // on the wire when the user omits the flag, and it shows in + // `--help` via clap's `[default: ...]`. Stored on + // `MethodParameter::default_value`. + // * `default:` (OpenAPI standard) is a DOCUMENTATION HINT about + // server behavior. It is rendered as ` [API default: ...]` in + // `--help` but never sent on the wire. Stored on + // `MethodParameter::documentation_default_value`. + // + // Within `x-fern-default`, fern's openapi-ir-parser precedence + // applies: ref-site beats the resolved component parameter, i.e. + // getExtension(parameter, FERN_DEFAULT) + // ?? getExtension(resolvedParameter, FERN_DEFAULT). + // + // When `x-fern-default` is present, the schema `default:` is + // dropped from `documentation_default_value` too so `--help` + // doesn't render two conflicting `[default: ...]` lines. + // ----------------------------------------------------------------- + + fn fern_default_yaml(parameters_block: &str) -> String { + format!( + r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: +{parameters_block} + responses: + '200': + description: OK +"# + ) + } + + #[test] + fn test_default_value_absent_when_no_default_anywhere() { + // Sanity check: omitting both `default:` and `x-fern-default` + // leaves both fields `None` — no clap default and no help-text + // suffix get emitted. + let yaml = fern_default_yaml( + " - name: cursor\n in: query\n schema:\n type: string", + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let cursor = doc.resources["users"].methods["list"] + .parameters + .get("cursor") + .unwrap(); + assert!(cursor.default_value.is_none()); + assert!(cursor.documentation_default_value.is_none()); + } + + #[test] + fn test_standard_openapi_default_lowers_as_documentation_only() { + // OpenAPI's standard `default:` describes server behavior and is + // doc-only for the CLI: it must populate the documentation field + // (so `--help` can mention it) but must NOT populate the + // client-side default field — sending it on the wire when the + // caller omits the flag would change the API contract. Numbers + // keep their JSON type so the help-text suffix renders `25` not + // `"25"`. + let yaml = fern_default_yaml( + " - name: limit\n in: query\n schema:\n type: integer\n default: 100", + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let limit = doc.resources["users"].methods["list"] + .parameters + .get("limit") + .unwrap(); + assert!( + limit.default_value.is_none(), + "schema `default:` must not produce a client-side default" + ); + assert_eq!( + limit.documentation_default_value, + Some(serde_json::Value::Number(100.into())), + "schema `default: 100` should round-trip as a JSON number on the documentation field" + ); + } + + #[test] + fn test_x_fern_default_alone_lowers_as_client_default() { + // `x-fern-default` with no standard `default:` is plumbed into + // the client-side `default_value` field. Covers string, boolean, + // and integer scalar forms — the documentation field stays + // `None` because there is no schema `default:` to surface. + let yaml = fern_default_yaml( + r#" - name: region + in: query + x-fern-default: "us-east-1" + schema: + type: string + - name: enabled + in: query + x-fern-default: true + schema: + type: boolean + - name: pageSize + in: query + x-fern-default: 50 + schema: + type: integer"#, + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let params = &doc.resources["users"].methods["list"].parameters; + assert_eq!( + params["region"].default_value, + Some(serde_json::Value::String("us-east-1".to_string())) + ); + assert!(params["region"].documentation_default_value.is_none()); + assert_eq!( + params["enabled"].default_value, + Some(serde_json::Value::Bool(true)) + ); + assert!(params["enabled"].documentation_default_value.is_none()); + assert_eq!( + params["pageSize"].default_value, + Some(serde_json::Value::Number(50.into())) + ); + assert!(params["pageSize"].documentation_default_value.is_none()); + } + + #[test] + fn test_x_fern_default_supersedes_schema_default_for_help_too() { + // When both are present we want the client-side default field + // populated AND the documentation field cleared, so `--help` + // doesn't render two conflicting `[default: ...]` lines. The + // user-visible default is what the CLI will actually do (send + // `50`); the underlying server default is intentionally hidden + // because the API author opted into overriding it. + let yaml = fern_default_yaml( + r#" - name: limit + in: query + x-fern-default: 50 + schema: + type: integer + default: 100"#, + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let limit = doc.resources["users"].methods["list"] + .parameters + .get("limit") + .unwrap(); + assert_eq!( + limit.default_value, + Some(serde_json::Value::Number(50.into())), + "x-fern-default must drive the client-side default" + ); + assert!( + limit.documentation_default_value.is_none(), + "schema.default should not also be surfaced when x-fern-default is set" + ); + } + + #[test] + fn test_x_fern_default_at_ref_site_wins_over_resolved_component() { + // Ref-site precedence: when `x-fern-default` is placed alongside + // a `$ref`, it wins over the value on the resolved component + // parameter. Mirrors fern's `getExtension(parameter, FERN_DEFAULT) + // ?? getExtension(resolvedParameter, FERN_DEFAULT)`. The schema + // `default:` (a doc hint) is also suppressed because the + // client-side default takes over the `--help` slot. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Region' + x-fern-default: "eu-west-1" + responses: + '200': + description: OK +components: + parameters: + Region: + name: region + in: query + x-fern-default: "us-east-1" + schema: + type: string + default: "us-west-2" +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let region = doc.resources["users"].methods["list"] + .parameters + .get("region") + .unwrap(); + assert_eq!( + region.default_value, + Some(serde_json::Value::String("eu-west-1".to_string())), + "ref-site x-fern-default must win over both component-level x-fern-default and schema.default" + ); + assert!( + region.documentation_default_value.is_none(), + "schema.default should be suppressed when a client-side default exists" + ); + } + + #[test] + fn test_x_fern_default_from_resolved_component_when_no_ref_site_override() { + // Fallback half of the precedence: with no ref-site + // `x-fern-default`, the value on the resolved component + // parameter populates the client-side default, and the schema + // `default:` is still suppressed because the client-side slot + // is taken. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Region' + responses: + '200': + description: OK +components: + parameters: + Region: + name: region + in: query + x-fern-default: "us-east-1" + schema: + type: string + default: "us-west-2" +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let region = doc.resources["users"].methods["list"] + .parameters + .get("region") + .unwrap(); + assert_eq!( + region.default_value, + Some(serde_json::Value::String("us-east-1".to_string())) + ); + assert!(region.documentation_default_value.is_none()); + } + + #[test] + fn test_schema_default_via_ref_lowers_as_documentation_only() { + // Even when the parameter is reached via `$ref`, a schema-level + // `default:` with no `x-fern-default` anywhere must NOT become a + // client-side default. It populates the documentation field so + // `--help` can surface `[API default: us-west-2]` without + // forcing the CLI to send the value on the wire. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Region' + responses: + '200': + description: OK +components: + parameters: + Region: + name: region + in: query + schema: + type: string + default: "us-west-2" +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let region = doc.resources["users"].methods["list"] + .parameters + .get("region") + .unwrap(); + assert!( + region.default_value.is_none(), + "schema.default reached via $ref must stay doc-only" + ); + assert_eq!( + region.documentation_default_value, + Some(serde_json::Value::String("us-west-2".to_string())) + ); + } + + #[test] + fn test_inline_request_body_produces_per_field_body_params() { + // An inline object schema in `requestBody` should expose each top-level + // property as a body-located MethodParameter so that the command builder + // can render per-field flags. Read-only fields are skipped, and required + // fields keep their `required` bit so the executor can enforce them. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + post: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + count: + type: integer + tags: + type: array + items: + type: string + server_generated_id: + type: string + readOnly: true + responses: + "201": + description: created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["things"].methods["create"]; + + let name = create + .parameters + .get("name") + .expect("name should be a body param"); + assert_eq!(name.location.as_deref(), Some("body")); + assert_eq!(name.param_type.as_deref(), Some("string")); + assert!(name.required, "name is in `required` and should be marked"); + + let count = create + .parameters + .get("count") + .expect("count should be a body param"); + assert_eq!(count.location.as_deref(), Some("body")); + assert_eq!(count.param_type.as_deref(), Some("integer")); + assert!(!count.required); + + // Array body properties become repeated flags (repeated: true, param_type: string). + let tags = create + .parameters + .get("tags") + .expect("tags should be a body param"); + assert_eq!(tags.location.as_deref(), Some("body")); + assert!(tags.repeated, "array body prop should have repeated: true"); + assert_eq!(tags.param_type.as_deref(), Some("string")); + + // Read-only fields don't get a flag — they're server-managed. + assert!( + !create.parameters.contains_key("server_generated_id"), + "readOnly properties should be skipped" + ); + } + + #[test] + fn test_body_depth_3_plus_not_flattened() { + // Mirrors MAX_INPUT_DEPTH in graphql/parser.rs: depths 0, 1, 2 are + // flattened into dot-notation flags; depth >= 3 is not. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + post: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + address: + type: object + properties: + city: + type: string + location: + type: object + properties: + street: + type: string + geo: + type: object + properties: + lat: + type: number + responses: + "201": + description: created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["users"].methods["create"]; + + // Depth-0: top-level scalar. + assert!(create.parameters.contains_key("name"), "depth-0 'name' should be a flag"); + + // Depth-1: one level of nesting. + assert!(create.parameters.contains_key("address.city"), "depth-1 'address.city' should be a flag"); + + // Depth-2: two levels of nesting — now emitted (matches GraphQL behaviour). + assert!(create.parameters.contains_key("address.location.street"), "depth-2 'address.location.street' should be a flag"); + + // Depth-3: NOT emitted — beyond MAX_BODY_DEPTH. + assert!(!create.parameters.contains_key("address.location.geo.lat"), "depth-3 'address.location.geo.lat' must not be a flag"); + // address.location.geo surfaces as a plain object flag (depth limit hit, recursion returns empty). + assert!(create.parameters.contains_key("address.location.geo"), "depth-2 object at limit should surface as plain flag"); + } + + #[test] + fn test_ref_property_within_inline_schema_resolved() { + // A property within an inline body schema that uses $ref should be + // resolved from components/schemas rather than emitted as a typeless flag. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /orders: + post: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + properties: + note: + type: string + address: + $ref: '#/components/schemas/Address' + responses: + "201": + description: Created order +components: + schemas: + Address: + type: object + properties: + city: + type: string + zip: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["orders"].methods["create"]; + + // Top-level scalar — present as-is. + assert!(create.parameters.contains_key("note"), "'note' should be a flag"); + + // $ref to an object at depth 0 — resolved and flattened into dot-notation flags. + assert!(create.parameters.contains_key("address.city"), "'address.city' should be a flag after $ref resolution"); + assert!(create.parameters.contains_key("address.zip"), "'address.zip' should be a flag after $ref resolution"); + + // The $ref itself should NOT appear as a typeless flag. + assert!(!create.parameters.contains_key("address"), "'address' $ref should not appear as a bare typeless flag"); + } + + #[test] + fn test_inline_body_does_not_clobber_query_params_with_same_name() { + // If a body schema property collides with an existing query/path/header + // parameter, the spec's `parameters` array wins — body-flag generation + // shouldn't silently turn a query param into a body param. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + post: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: create + parameters: + - name: name + in: query + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + responses: + "201": + description: created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["things"].methods["create"]; + + // `name` was claimed by the query param first — it stays a query param. + let name = &create.parameters["name"]; + assert_eq!(name.location.as_deref(), Some("query")); + + // `description` doesn't collide, lands in the body normally. + let description = &create.parameters["description"]; + assert_eq!(description.location.as_deref(), Some("body")); + } + + #[test] + fn test_per_operation_server_override() { + let yaml = r#" +openapi: "3.0.0" +info: + title: "API" + version: "1.0" +servers: + - url: "https://api.example.com" +paths: + /upload: + post: + servers: + - url: "https://upload.example.com" + x-fern-sdk-group-name: ["uploads"] + x-fern-sdk-method-name: create + responses: + "200": + description: ok + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Upload operation has its own server — should use it + let upload = doc.resources["uploads"].methods["create"].clone(); + assert_eq!(upload.root_url, "https://upload.example.com"); + // Users operation has no server override — falls back to spec-level + let users = doc.resources["users"].methods["list"].clone(); + assert_eq!(users.root_url, "https://api.example.com"); + } + + // ------------------------------------------------------------------ + // x-fern-idempotent + x-fern-idempotency-headers (FER-9864 P1). + // ------------------------------------------------------------------ + + /// Spec-root `x-fern-idempotency-headers` lowers to + /// `RestDescription.idempotency_headers` with the same shape, and an + /// operation marked `x-fern-idempotent: true` carries that flag + /// through to `RestMethod.idempotent`. + #[test] + fn test_idempotency_headers_parsed_from_spec_root() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + name: idempotency_key + env: API_IDEMPOTENCY_KEY + - header: X-Trace-Id +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!(doc.idempotency_headers.len(), 2, "both header entries parsed"); + assert_eq!(doc.idempotency_headers[0].header, "Idempotency-Key"); + assert_eq!(doc.idempotency_headers[0].name.as_deref(), Some("idempotency_key")); + assert_eq!(doc.idempotency_headers[0].env.as_deref(), Some("API_IDEMPOTENCY_KEY")); + assert_eq!(doc.idempotency_headers[1].header, "X-Trace-Id"); + assert!(doc.idempotency_headers[1].name.is_none()); + assert!(doc.idempotency_headers[1].env.is_none()); + } + + /// `x-fern-idempotent: true` toggles `RestMethod.idempotent` and + /// synthesizes one header `MethodParameter` per spec-root entry. A + /// sibling operation without the extension is unaffected — its + /// parameter map contains no idempotency-header entries, which is + /// what guarantees the flags are not surfaced and the header is not + /// sent on non-idempotent ops. + #[test] + fn test_idempotent_op_surfaces_header_param_non_idempotent_does_not() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + name: idempotency_key + env: API_IDEMPOTENCY_KEY +paths: + /payments: + get: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: list + operationId: payments_list + responses: + "200": + description: ok + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let payments = doc.resources.get("payments").expect("payments group"); + + // Idempotent op + let create = payments.methods.get("create").expect("create method"); + assert!(create.idempotent, "create is x-fern-idempotent: true"); + let idem_param = create + .parameters + .get("Idempotency-Key") + .expect("synthetic idempotency header parameter exists"); + assert_eq!(idem_param.location.as_deref(), Some("header")); + assert_eq!(idem_param.env_var.as_deref(), Some("API_IDEMPOTENCY_KEY")); + + // Non-idempotent sibling + let list = payments.methods.get("list").expect("list method"); + assert!(!list.idempotent, "list is not idempotent"); + assert!( + !list.parameters.contains_key("Idempotency-Key"), + "non-idempotent op must not surface idempotency-header params", + ); + } + + /// An operation marked idempotent but with no spec-root header + /// definitions still flips `idempotent = true`; no synthetic + /// parameters are added because there are no headers to inject. + #[test] + fn test_idempotent_op_without_spec_root_headers_has_no_synthetic_params() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.idempotency_headers.is_empty()); + let create = &doc.resources["payments"].methods["create"]; + assert!(create.idempotent); + assert!( + create.parameters.is_empty(), + "no synthetic params without spec-root header definitions", + ); + } + + /// When the `IdempotencyHeader` entry sets `name`, the synthetic + /// `MethodParameter` carries a `flag_name_override` derived from + /// `to_kebab_flag(name)`. The HashMap key remains the wire header + /// name (so the executor still sends the correct HTTP header). + /// This is the case the upstream Fern OpenAPI importer's SDK + /// parameter naming covers — a header like `X-Trace-Id` with + /// `name: trace_id` materializes as `--trace-id` on the CLI, not + /// `--x-trace-id`. + #[test] + fn test_idempotent_op_uses_name_for_flag_derivation() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: X-Trace-Id + name: trace_id + - header: Idempotency-Key +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["payments"].methods["create"]; + + // X-Trace-Id with `name: trace_id` → wire key stays + // `X-Trace-Id`, but the flag becomes `--trace-id`. + let trace = create.parameters.get("X-Trace-Id").unwrap(); + assert_eq!(trace.flag_name_override.as_deref(), Some("trace-id")); + assert_eq!(trace.location.as_deref(), Some("header")); + + // No `name` → no override; flag derives from the header name + // via the existing `to_kebab_flag` path in `commands.rs`. + let idem = create.parameters.get("Idempotency-Key").unwrap(); + assert!(idem.flag_name_override.is_none()); + } + + /// Spec-declared parameters always win over a synthetic injection + /// with the same key — a per-operation `Idempotency-Key` declaration + /// keeps its description, schema, and any other customizations the + /// author put on it. + #[test] + fn test_spec_declared_param_wins_over_injection() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + env: API_IDEMPOTENCY_KEY +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + parameters: + - name: Idempotency-Key + in: header + description: Custom description from author. + schema: + type: string + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["payments"].methods["create"]; + let p = create + .parameters + .get("Idempotency-Key") + .expect("declared param present"); + assert_eq!(p.description.as_deref(), Some("Custom description from author.")); + assert!( + p.env_var.is_none(), + "spec-declared param does not pick up env_var from the spec-root extension", + ); + } + + // ------------------------------------------------------------------ + // x-fern-global-headers (FER-9864 P2). + // ------------------------------------------------------------------ + + /// Absent extension → empty `global_headers` (the default-empty + /// `Vec` codepath). Pins the wire-compat baseline so a spec that + /// does not opt in is not changed. + #[test] + fn test_global_headers_absent_yields_empty_vec() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.global_headers.is_empty()); + } + + /// Full entry round-trips every field through the parser into + /// `RestDescription.global_headers`. Mirrors the upstream Fern + /// importer shape from `getGlobalHeaders.ts`. + #[test] + fn test_global_headers_full_entry_round_trips() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-API-Version + name: apiVersion + optional: false + env: API_VERSION + default: "2024-01-01" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!(doc.global_headers.len(), 1); + let h = &doc.global_headers[0]; + assert_eq!(h.header, "X-API-Version"); + assert_eq!(h.name.as_deref(), Some("apiVersion")); + assert!(!h.optional); + assert_eq!(h.env.as_deref(), Some("API_VERSION")); + assert_eq!(h.default.as_deref(), Some("2024-01-01")); + } + + /// Optional fields absent → defaults applied: `name` and `env` and + /// `default` are `None`, `optional` falls back to `false` (matching + /// upstream Fern's `?? false` default). + #[test] + fn test_global_headers_minimal_entry_uses_defaults() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-Trace-Id +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let h = &doc.global_headers[0]; + assert_eq!(h.header, "X-Trace-Id"); + assert!(h.name.is_none()); + assert!(!h.optional, "optional defaults to false (i.e. required)"); + assert!(h.env.is_none()); + assert!(h.default.is_none()); + } + + /// `optional: true` lowers to `GlobalHeader.optional = true`. + /// Surfaces the required/optional toggle that the CLI registration + /// path consumes to decide whether to error on a missing value. + #[test] + fn test_global_headers_optional_true_lowers_to_optional() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-Optional + optional: true +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.global_headers[0].optional); + } + + /// `default` accepts string / bool / number — they all lower to a + /// string for the outgoing HTTP header. Anything else (null / + /// sequence / mapping) drops to `None` rather than crashing. + #[test] + fn test_global_headers_default_accepts_scalar_types() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-String + default: "literal" + - header: X-Bool + default: true + - header: X-Number + default: 42 + - header: X-Null + default: null +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let by_header = |name: &str| -> &crate::openapi::discovery::GlobalHeader { + doc.global_headers + .iter() + .find(|h| h.header == name) + .expect("header parsed") + }; + assert_eq!(by_header("X-String").default.as_deref(), Some("literal")); + assert_eq!(by_header("X-Bool").default.as_deref(), Some("true")); + assert_eq!(by_header("X-Number").default.as_deref(), Some("42")); + assert!( + by_header("X-Null").default.is_none(), + "`null` is not a usable HTTP header value, so it drops to None" + ); + } + + /// `x-fern-default` takes precedence over `default` when both are + /// present, mirroring the upstream Fern importer where the + /// Fern-namespaced field is the authoritative source for header + /// defaults. + #[test] + fn test_global_headers_x_fern_default_overrides_default() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-Stage + default: "production" + x-fern-default: "development" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.global_headers[0].default.as_deref(), + Some("development"), + "x-fern-default wins over default" + ); + } + + /// Multiple entries preserve declaration order. The registration + /// pass in `app.rs` later iterates this Vec to register flags, and + /// help-text ordering follows source order — pin that here so the + /// surface is stable across refactors. + #[test] + fn test_global_headers_preserves_declaration_order() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: First + - header: Second + - header: Third +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let headers: Vec<&str> = + doc.global_headers.iter().map(|h| h.header.as_str()).collect(); + assert_eq!(headers, vec!["First", "Second", "Third"]); + } + + // ------------------------------------------------------------------ + // x-fern-groups (FER-9864 P3). + // + // Document-root extension that decorates `x-fern-sdk-group-name` + // groups with `summary` / `description` metadata for the help + // surface. Shape mirrors the upstream Fern OpenAPI importer's + // `XFernGroupsSchema` zod schema and matching `SdkGroupInfo` IR + // type: + // fern-api/fern packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernGroups.ts:8-14 + // fern-api/fern packages/cli/api-importers/openapi/openapi-ir/fern/definition/finalIr.yml:51-54 + // ------------------------------------------------------------------ + + const X_FERN_GROUPS_SPEC_SKELETON: &str = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + + /// Baseline: with no `x-fern-groups` extension on the document + /// root, `RestDescription::groups` is the empty map. This is the + /// "feature opted out" path — every consumer that calls + /// `doc.groups.get(...)` falls back to the legacy + /// `Operations on ''` rendering. + #[test] + fn test_x_fern_groups_absent_yields_empty_map() { + let doc = load_openapi_spec(X_FERN_GROUPS_SPEC_SKELETON, "test").unwrap(); + assert!(doc.groups.is_empty()); + } + + /// Single-group case: both `summary` and `description` flow + /// through to `SdkGroupInfo` verbatim. Verifies the kebab-cased + /// lookup key matches the resource-tree key the command builder + /// uses. + #[test] + fn test_x_fern_groups_single_group_round_trips() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations + description: Long-form prose explaining the things group. +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let info = doc.groups.get("things").expect("things entry present"); + assert_eq!(info.summary.as_deref(), Some("Things Operations")); + assert_eq!( + info.description.as_deref(), + Some("Long-form prose explaining the things group.") + ); + } + + /// Multiple groups parse independently. Order is irrelevant for + /// the HashMap lookup, so the test asserts on per-key shape rather + /// than iteration order. + #[test] + fn test_x_fern_groups_multiple_groups_parse_independently() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations + widgets: + summary: Widgets Operations + description: A second group. +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok + /widgets: + get: + x-fern-sdk-group-name: [widgets] + x-fern-sdk-method-name: list + operationId: widgets_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!(doc.groups.len(), 2); + assert_eq!( + doc.groups["things"].summary.as_deref(), + Some("Things Operations"), + ); + assert!(doc.groups["things"].description.is_none()); + assert_eq!( + doc.groups["widgets"].summary.as_deref(), + Some("Widgets Operations"), + ); + assert_eq!( + doc.groups["widgets"].description.as_deref(), + Some("A second group."), + ); + } + + /// Summary-only entry: `description` stays `None` so the command + /// builder falls back to the `about()` text when rendering + /// `--long-help`. + #[test] + fn test_x_fern_groups_summary_only_keeps_description_none() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let info = doc.groups.get("things").expect("things entry present"); + assert_eq!(info.summary.as_deref(), Some("Things Operations")); + assert!(info.description.is_none()); + } + + /// Description-only entry: `summary` stays `None`. The command + /// builder then keeps the legacy `Operations on ''` about + /// line while still surfacing the description via + /// `long_about()`. + #[test] + fn test_x_fern_groups_description_only_keeps_summary_none() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + description: Long-form prose about the group. +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let info = doc.groups.get("things").expect("things entry present"); + assert!(info.summary.is_none()); + assert_eq!( + info.description.as_deref(), + Some("Long-form prose about the group."), + ); + } + + /// Group keys are kebab-cased so they line up with the resource + /// keys the command builder produces from `x-fern-sdk-group-name` + /// (which itself runs through `camel_to_kebab`). A `myGroup` entry + /// surfaces as `my-group`; the original casing is intentionally + /// not preserved. + #[test] + fn test_x_fern_groups_keys_are_kebab_cased() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + myGroup: + summary: Pretty Label +paths: + /things: + get: + x-fern-sdk-group-name: [myGroup] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.groups.contains_key("my-group")); + assert!(!doc.groups.contains_key("myGroup")); + assert_eq!( + doc.groups["my-group"].summary.as_deref(), + Some("Pretty Label"), + ); + } + + /// Unrelated extra fields inside a group entry are ignored + /// rather than rejected. Fern's `getFernGroups.ts` schema is a + /// `z.object({ summary, description })` (no `.strict()`), so the + /// importer also tolerates extras — we mirror that to stay + /// forward-compatible with the documented `groups:` nesting + /// field on the wire (which the cli-sdk does not consume). + #[test] + fn test_x_fern_groups_tolerates_unknown_fields() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations + groups: [other] +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.groups["things"].summary.as_deref(), + Some("Things Operations"), + ); + } + + // ------------------------------------------------------------------ + // Security scheme parsing + per-operation security inheritance. + // ------------------------------------------------------------------ + + fn first_method<'a>(doc: &'a RestDescription, group: &str, method: &str) -> &'a RestMethod { + doc.resources + .get(group) + .unwrap_or_else(|| panic!("resource '{group}' missing")) + .methods + .get(method) + .unwrap_or_else(|| panic!("method '{method}' on '{group}' missing")) + } + + #[test] + fn test_parses_http_bearer_security_scheme() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("bearerAuth"), + Some(&SecurityScheme::HttpBearer), + ); + } + + #[test] + fn test_parses_http_basic_security_scheme() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + basicAuth: + type: http + scheme: basic +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("basicAuth"), + Some(&SecurityScheme::HttpBasic), + ); + } + + #[test] + fn test_parses_apikey_header_and_query() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + headerKey: + type: apiKey + in: header + name: X-Api-Key + queryKey: + type: apiKey + in: query + name: api_key +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("headerKey"), + Some(&SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }), + ); + assert_eq!( + doc.security_schemes.get("queryKey"), + Some(&SecurityScheme::ApiKeyQuery { + name: "api_key".to_string(), + }), + ); + } + + #[test] + fn test_parses_oauth2_security_scheme_as_oauth2() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + oauthScheme: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://x.com/token + scopes: + read: read scope +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("oauthScheme"), + Some(&SecurityScheme::OAuth2), + ); + } + + #[test] + fn test_unknown_security_type_falls_through_to_other() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + weird: + type: mutualTLS +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + match doc.security_schemes.get("weird") { + Some(SecurityScheme::Other(s)) => assert_eq!(s, "mutualtls"), + other => panic!("unexpected scheme: {other:?}"), + } + } + + #[test] + fn test_operation_inherits_spec_level_security() { + // Top-level `security: [{bearerAuth: []}]` is inherited by an + // operation that doesn't declare its own. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let reqs = m + .security_requirements + .as_ref() + .expect("inherited requirements present"); + assert_eq!(reqs.len(), 1); + assert!(reqs[0].contains_key("bearerAuth")); + } + + #[test] + fn test_operation_security_overrides_spec_default() { + // Operation declares its own `security` — that wins over the spec + // default, even if it picks a different scheme. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer } + apiKey: { type: apiKey, in: header, name: X-Api-Key } +paths: + /admin: + get: + x-fern-sdk-group-name: ["admin"] + x-fern-sdk-method-name: ping + security: + - apiKey: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "admin", "ping"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert_eq!(reqs.len(), 1); + assert!(reqs[0].contains_key("apiKey")); + assert!(!reqs[0].contains_key("bearerAuth")); + } + + #[test] + fn test_explicit_empty_operation_security_means_anonymous() { + // `security: []` on an operation is meaningful — it explicitly opts + // out of the spec-level default, marking the endpoint anonymous. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer } +paths: + /public: + get: + x-fern-sdk-group-name: ["public"] + x-fern-sdk-method-name: ping + security: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "public", "ping"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert!( + reqs.is_empty(), + "explicit empty array should produce Some(vec![]), got {reqs:?}", + ); + } + + #[test] + fn test_no_security_anywhere_leaves_requirements_none() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + assert!(m.security_requirements.is_none()); + } + + #[test] + fn test_spec_level_empty_security_inherited_as_anonymous() { + // `security: []` at the spec root means every operation is + // anonymous by default unless it declares its own. Inheritance + // should propagate the explicit empty vec through. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: [] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert!( + reqs.is_empty(), + "spec-level explicit anonymous should propagate, got {reqs:?}", + ); + } + + #[test] + fn test_security_scheme_type_and_scheme_are_case_insensitive() { + // OpenAPI doesn't formally constrain casing on `type` / `scheme`; + // real-world specs vary. Match generously. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + a: + type: HTTP + scheme: Bearer + b: + type: ApiKey + in: HEADER + name: X-Api-Key +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.security_schemes.get("a"), Some(&SecurityScheme::HttpBearer)); + assert_eq!( + doc.security_schemes.get("b"), + Some(&SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }), + ); + } + + #[test] + fn test_operation_can_reference_undeclared_scheme() { + // An operation referencing a scheme not in components.securitySchemes + // is preserved verbatim — Phase 3's RoutingAuthProvider will simply + // have no binding for it and fall through. Some real-world specs + // reference externally-configured schemes this way. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /thing: + get: + x-fern-sdk-group-name: ["thing"] + x-fern-sdk-method-name: get + security: + - externalScheme: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "thing", "get"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert_eq!(reqs.len(), 1); + assert!(reqs[0].contains_key("externalScheme")); + // No declaration in components.securitySchemes — that's fine. + assert!(doc.security_schemes.is_empty()); + } + + #[test] + fn test_or_of_ands_security_requirements() { + // The classic `[{a: []}, {b: [], c: []}]` shape: satisfy a alone, OR + // (b AND c). Verifies we preserve the structure verbatim. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + a: { type: http, scheme: bearer } + b: { type: apiKey, in: header, name: X-B } + c: { type: apiKey, in: header, name: X-C } +paths: + /complex: + get: + x-fern-sdk-group-name: ["complex"] + x-fern-sdk-method-name: get + security: + - a: [] + - b: [] + c: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "complex", "get"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert_eq!(reqs.len(), 2); + // First alternative: just `a`. + assert!(reqs[0].contains_key("a")); + assert_eq!(reqs[0].len(), 1); + // Second alternative: `b` AND `c`. + assert!(reqs[1].contains_key("b")); + assert!(reqs[1].contains_key("c")); + assert_eq!(reqs[1].len(), 2); + } + + // ----------------------------------------------------------------------- + // deep_merge_yaml tests — matches Fern CLI mergeWithOverrides behavior + // ----------------------------------------------------------------------- + + // -- Scalar / map basics ------------------------------------------------ + + #[test] + fn test_deep_merge_scalars_override_wins() { + let base: serde_yaml::Value = serde_yaml::from_str("title: Original").unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("title: Overridden").unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["title"], serde_yaml::Value::String("Overridden".into())); + } + + #[test] + fn test_deep_merge_adds_new_keys() { + let base: serde_yaml::Value = serde_yaml::from_str("a: 1").unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("b: 2").unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["a"], serde_yaml::Value::Number(1.into())); + assert_eq!(merged["b"], serde_yaml::Value::Number(2.into())); + } + + /// Fern CLI test: "should handle nested object merging" + #[test] + fn test_deep_merge_nested_object_merging() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + config: + settings: + theme: light + notifications: true + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + config: + settings: + theme: dark + sound: false + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["config"]["settings"]["theme"], serde_yaml::Value::String("dark".into())); + assert_eq!(merged["config"]["settings"]["notifications"], serde_yaml::Value::Bool(true)); + assert_eq!(merged["config"]["settings"]["sound"], serde_yaml::Value::Bool(false)); + } + + /// Fern CLI test: "deep-merges nested objects rather than replacing them" + #[test] + fn test_deep_merge_nested_sibling_keys() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + foo: + bar: + existingKey: original + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + foo: + bar: + newKey: added + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!( + merged["foo"]["bar"]["existingKey"], + serde_yaml::Value::String("original".into()) + ); + assert_eq!( + merged["foo"]["bar"]["newKey"], + serde_yaml::Value::String("added".into()) + ); + } + + #[test] + fn test_deep_merge_nested_openapi_paths() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + paths: + /users: + get: + summary: List users + operationId: listUsers + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + paths: + /users: + get: + x-fern-sdk-group-name: [users] + x-fern-sdk-method-name: list + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!( + merged["paths"]["/users"]["get"]["summary"], + serde_yaml::Value::String("List users".into()) + ); + assert_eq!( + merged["paths"]["/users"]["get"]["operationId"], + serde_yaml::Value::String("listUsers".into()) + ); + assert_eq!( + merged["paths"]["/users"]["get"]["x-fern-sdk-method-name"], + serde_yaml::Value::String("list".into()) + ); + } + + // -- Null deletion (omitDeepBy(isNull)) --------------------------------- + + #[test] + fn test_deep_merge_null_deletes_key() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + info: + title: API + description: A description + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + info: + description: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["info"]["title"], serde_yaml::Value::String("API".into())); + let info = merged["info"].as_mapping().unwrap(); + assert!(!info.contains_key("description"), "null should delete the key"); + } + + /// Fern CLI test: "removes null values from merged result" + #[test] + fn test_deep_merge_null_removes_from_merged_result() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + title: Title + description: A description + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + description: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["title"], serde_yaml::Value::String("Title".into())); + assert!(!merged.as_mapping().unwrap().contains_key("description")); + } + + /// Nulls inside non-allowlisted keys are still removed. + #[test] + fn test_deep_merge_removes_pre_existing_nulls_outside_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + type: object + properties: + name: + type: string + description: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let name = merged["properties"]["name"].as_mapping().unwrap(); + assert!(name.contains_key("type")); + assert!(!name.contains_key("description"), "null outside examples should be removed"); + } + + /// Fern CLI parity: nulls inside `examples` keys are preserved + /// (allowNullKeys = ["examples"]). + #[test] + fn test_deep_merge_preserves_nulls_inside_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + type: object + properties: + name: + type: string + examples: + example1: John + example2: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let examples = merged["properties"]["name"]["examples"].as_mapping().unwrap(); + assert!(examples.contains_key("example1")); + assert!(examples.contains_key("example2"), "null inside examples should be preserved"); + assert!(examples.get("example2").unwrap().is_null()); + } + + /// Nulls deeply nested under an `examples` key are also preserved. + #[test] + fn test_deep_merge_preserves_nulls_deeply_nested_under_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + examples: + myExample: + value: + name: John + email: null + nested: + field: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let value = &merged["examples"]["myExample"]["value"]; + assert!(value["email"].is_null(), "null under examples descendant preserved"); + assert!(value["nested"]["field"].is_null(), "deeply nested null under examples preserved"); + } + + /// Nulls outside `examples` are removed even when siblings of examples. + #[test] + fn test_deep_merge_mixed_examples_and_non_examples_nulls() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + schema: + description: null + examples: + ex1: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let schema = merged["schema"].as_mapping().unwrap(); + assert!(!schema.contains_key("description"), "null outside examples removed"); + let examples = schema.get("examples").unwrap().as_mapping().unwrap(); + assert!(examples.contains_key("ex1"), "null inside examples preserved"); + } + + /// Null deletion should be recursive through deeply nested maps. + #[test] + fn test_deep_merge_null_deletes_deeply_nested() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + a: + b: + c: + keep: true + remove_me: value + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + a: + b: + c: + remove_me: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let c = merged["a"]["b"]["c"].as_mapping().unwrap(); + assert!(c.contains_key("keep")); + assert!(!c.contains_key("remove_me")); + } + + // -- Array of primitives: replaced wholesale (Fern parity) -------------- + + /// Fern CLI test: "should replace arrays of primitives" + #[test] + fn test_deep_merge_primitive_arrays_replaced_wholesale() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + tags: [tag1, tag2] + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + tags: [tag3, tag4] + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let tags = merged["tags"].as_sequence().unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0], serde_yaml::Value::String("tag3".into())); + assert_eq!(tags[1], serde_yaml::Value::String("tag4".into())); + } + + #[test] + fn test_deep_merge_primitive_array_shorter_override_replaces() { + let base: serde_yaml::Value = serde_yaml::from_str("tags: [a, b, c]").unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("tags: [x]").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let tags = merged["tags"].as_sequence().unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0], serde_yaml::Value::String("x".into())); + } + + // -- Arrays of objects: merged element-by-element (Fern parity) --------- + + /// Fern CLI test: "should merge arrays of objects" + #[test] + fn test_deep_merge_object_arrays_merged_by_index() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - id: 1 + name: Item 1 + - id: 2 + name: Item 2 + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - id: 1 + description: Updated Item 1 + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let items = merged["items"].as_sequence().unwrap(); + // Element 0 merged: base {id:1, name: Item 1} + override {id:1, description: Updated Item 1} + assert_eq!(items[0]["id"], serde_yaml::Value::Number(1.into())); + assert_eq!(items[0]["name"], serde_yaml::Value::String("Item 1".into())); + assert_eq!(items[0]["description"], serde_yaml::Value::String("Updated Item 1".into())); + // Element 1 carried from base (override only has 1 element) + assert_eq!(items.len(), 2); + assert_eq!(items[1]["id"], serde_yaml::Value::Number(2.into())); + assert_eq!(items[1]["name"], serde_yaml::Value::String("Item 2".into())); + } + + /// Override array longer than base — extra elements appended. + #[test] + fn test_deep_merge_object_arrays_override_longer() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + servers: + - url: "https://a.com" + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + servers: + - url: "https://a-patched.com" + - url: "https://b.com" + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let servers = merged["servers"].as_sequence().unwrap(); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0]["url"], serde_yaml::Value::String("https://a-patched.com".into())); + assert_eq!(servers[1]["url"], serde_yaml::Value::String("https://b.com".into())); + } + + /// OpenAPI parameters array (array of objects) should merge by index. + #[test] + fn test_deep_merge_parameters_array_merges_by_index() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + parameters: + - name: limit + in: query + required: false + - name: offset + in: query + required: false + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + parameters: + - description: Maximum number of results + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let params = merged["parameters"].as_sequence().unwrap(); + assert_eq!(params.len(), 2); + // First param: merged with override + assert_eq!(params[0]["name"], serde_yaml::Value::String("limit".into())); + assert_eq!(params[0]["description"], serde_yaml::Value::String("Maximum number of results".into())); + // Second param: untouched from base + assert_eq!(params[1]["name"], serde_yaml::Value::String("offset".into())); + } + + // -- Mixed arrays (primitives + objects): replaced wholesale ------------- + + #[test] + fn test_deep_merge_mixed_array_replaced_wholesale() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + mixed: + - name: obj + - just_a_string + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + mixed: + - replaced: true + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let mixed = merged["mixed"].as_sequence().unwrap(); + // Base had mixed types → override replaces wholesale + assert_eq!(mixed.len(), 1); + } + + // -- Enum arrays (primitives) in schemas -------------------------------- + + #[test] + fn test_deep_merge_enum_array_replaced() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + accountStatus: + type: string + enum: [active, suspended, deleted] + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + accountStatus: + enum: [active, suspended, deleted, inactive] + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let enums = merged["accountStatus"]["enum"].as_sequence().unwrap(); + assert_eq!(enums.len(), 4); + assert_eq!(enums[3], serde_yaml::Value::String("inactive".into())); + // type preserved from base + assert_eq!(merged["accountStatus"]["type"], serde_yaml::Value::String("string".into())); + } + + // -- Overrides-resolution fixture parity -------------------------------- + + /// Matches the Fern CLI overrides-resolution fixture: override adds a new + /// property (lastName) to an existing schema, preserving existing ones. + #[test] + fn test_deep_merge_override_adds_schema_property() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + UserUpdate: + type: object + properties: + name: + type: string + email: + type: string + nullable: true + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + UserUpdate: + type: object + properties: + name: + type: string + lastName: + type: string + email: + type: string + nullable: true + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let props = merged["components"]["schemas"]["UserUpdate"]["properties"] + .as_mapping().unwrap(); + assert!(props.contains_key("name")); + assert!(props.contains_key("lastName"), "new property from override"); + assert!(props.contains_key("email")); + } + + /// Override introduces an entirely new schema that doesn't exist in the base. + #[test] + fn test_deep_merge_override_adds_new_schema() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + User: + type: object + properties: + id: + type: string + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + UserStats: + type: object + properties: + totalLogins: + type: integer + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let schemas = merged["components"]["schemas"].as_mapping().unwrap(); + assert!(schemas.contains_key("User"), "base schema preserved"); + assert!(schemas.contains_key("UserStats"), "new schema from override"); + } + + // -- Sequential override application ------------------------------------ + + #[test] + fn test_deep_merge_multiple_overrides_applied_sequentially() { + let base: serde_yaml::Value = serde_yaml::from_str("a: 1\nb: 2\nc: 3").unwrap(); + let ovr1: serde_yaml::Value = serde_yaml::from_str("a: 10\nd: 4").unwrap(); + let ovr2: serde_yaml::Value = serde_yaml::from_str("a: 100\nb: null").unwrap(); + let merged = deep_merge_yaml(deep_merge_yaml(base, ovr1), ovr2); + assert_eq!(merged["a"], serde_yaml::Value::Number(100.into())); + assert!(!merged.as_mapping().unwrap().contains_key("b")); + assert_eq!(merged["c"], serde_yaml::Value::Number(3.into())); + assert_eq!(merged["d"], serde_yaml::Value::Number(4.into())); + } + + // -- Empty overrides is identity ---------------------------------------- + + #[test] + fn test_deep_merge_empty_override_is_identity() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + info: + title: API + version: "1.0" + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base.clone(), overrides); + // With the exception of pre-existing nulls being removed, result + // should match. This base has none, so it should be identical. + assert_eq!(merged["info"]["title"], base["info"]["title"]); + assert_eq!(merged["info"]["version"], base["info"]["version"]); + } + + // -- End-to-end: override adds Fern extensions, parser reflects them ---- + + #[test] + fn test_deep_merge_override_adds_fern_extensions_to_spec() { + let base_yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } +"#; + let overrides_yaml = r#" +paths: + /customers: + get: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: list +"#; + // Without overrides: method name from operationId + let doc_no_override = load_openapi_spec(base_yaml, "t").unwrap(); + let customers = &doc_no_override.resources["customers"]; + assert!(customers.methods.contains_key("get-customers")); + + // With overrides: method name from x-fern-sdk-method-name + let base_val: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); + let ovr_val: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); + let merged = deep_merge_yaml(base_val, ovr_val); + let doc_with_override = load_openapi_spec_from_value(merged, "t").unwrap(); + let customers = &doc_with_override.resources["customers"]; + assert!( + customers.methods.contains_key("list"), + "override should set method name to 'list', got keys: {:?}", + customers.methods.keys().collect::>() + ); + } + + /// Multi-operation override: adds fern extensions to multiple endpoints. + #[test] + fn test_deep_merge_multi_operation_fern_extensions() { + let base_yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } + post: + tags: [Customers] + operationId: createCustomer + responses: { "201": { description: created } } + /orders: + get: + tags: [Orders] + operationId: getOrders + responses: { "200": { description: ok } } +"#; + let overrides_yaml = r#" +paths: + /customers: + get: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: list + post: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: create + /orders: + get: + x-fern-sdk-group-name: [orders] + x-fern-sdk-method-name: list +"#; + let base_val: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); + let ovr_val: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); + let merged = deep_merge_yaml(base_val, ovr_val); + let doc = load_openapi_spec_from_value(merged, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!(customers.methods.contains_key("list")); + assert!(customers.methods.contains_key("create")); + let orders = &doc.resources["orders"]; + assert!(orders.methods.contains_key("list")); + } + + /// Override re-groups an operation into a different resource. + #[test] + fn test_deep_merge_override_changes_group_name() { + let base_yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /admin/users: + get: + tags: [Admin] + operationId: adminListUsers + responses: { "200": { description: ok } } +"#; + let overrides_yaml = r#" +paths: + /admin/users: + get: + x-fern-sdk-group-name: [admin, users] + x-fern-sdk-method-name: list +"#; + let base_val: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); + let ovr_val: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); + let merged = deep_merge_yaml(base_val, ovr_val); + let doc = load_openapi_spec_from_value(merged, "t").unwrap(); + let admin = &doc.resources["admin"]; + let users = &admin.resources["users"]; + assert!( + users.methods.contains_key("list"), + "override should place method under admin.users" + ); + } + + // -- Null removal inside arrays of objects ------------------------------ + + #[test] + fn test_deep_merge_null_removed_inside_object_array() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - name: keep + remove: value + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - remove: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let item = &merged["items"].as_sequence().unwrap()[0]; + let map = item.as_mapping().unwrap(); + assert!(map.contains_key("name")); + assert!(!map.contains_key("remove"), "null inside object array element should be removed"); + } + + // -- Verification: allowNullKeys covers all Fern CLI keys --------------- + + #[test] + fn test_allow_null_keys_covers_all_fern_cli_keys() { + assert!(ALLOW_NULL_KEYS.contains(&"examples")); + assert!(ALLOW_NULL_KEYS.contains(&"example")); + assert!(ALLOW_NULL_KEYS.contains(&"x-fern-examples")); + assert!(ALLOW_NULL_KEYS.contains(&"x-code-samples")); + assert!(ALLOW_NULL_KEYS.contains(&"x-codeSamples")); + assert_eq!(ALLOW_NULL_KEYS.len(), 5, "should have exactly 5 keys matching Fern CLI"); + } + + #[test] + fn test_null_preserved_under_example_singular() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + schema: + example: null + description: null + "#).unwrap(); + let merged = deep_merge_yaml(base.clone(), serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + let map = merged.as_mapping().unwrap(); + let schema = map.get("schema").unwrap().as_mapping().unwrap(); + assert!(schema.contains_key("example"), "'example' (singular) null should be preserved"); + assert!(!schema.contains_key("description"), "'description' null should be removed"); + } + + #[test] + fn test_null_preserved_under_x_fern_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + x-fern-examples: + - value: null + "#).unwrap(); + let merged = deep_merge_yaml(base, serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + let seq = merged["x-fern-examples"].as_sequence().unwrap(); + let item = seq[0].as_mapping().unwrap(); + assert!(item.get("value").unwrap().is_null(), "null under x-fern-examples should be preserved"); + } + + #[test] + fn test_null_preserved_under_x_code_samples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + x-code-samples: + - lang: python + source: null + "#).unwrap(); + let merged = deep_merge_yaml(base, serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + let item = &merged["x-code-samples"].as_sequence().unwrap()[0]; + assert!(item["source"].is_null(), "null under x-code-samples should be preserved"); + } + + // -- Verification: all_objects heuristic -------------------------------- + + #[test] + fn test_all_objects_empty_arrays_vacuous_truth() { + assert!(all_objects(&[]), "empty array should pass all_objects (vacuous truth)"); + let base: serde_yaml::Value = serde_yaml::from_str("items: []").unwrap(); + let ovr: serde_yaml::Value = serde_yaml::from_str("items: []").unwrap(); + let merged = deep_merge_yaml(base, ovr); + assert_eq!(merged["items"].as_sequence().unwrap().len(), 0, "two empty arrays merge to empty"); + } + + #[test] + fn test_all_objects_servers_array_is_all_objects() { + let yaml = r#" +servers: + - url: https://api.example.com + - url: https://api2.example.com +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let servers = val["servers"].as_sequence().unwrap(); + assert!(all_objects(servers), "servers array should be all objects → index-merge path"); + } + + #[test] + fn test_all_objects_tags_array_is_primitives() { + let yaml = r#" +tags: + - Customers + - Orders +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tags = val["tags"].as_sequence().unwrap(); + assert!(!all_objects(tags), "string array should NOT be all objects → replace path"); + } + + // --------------------------------------------------------------- + // `x-fern-pagination` resolution + // --------------------------------------------------------------- + + fn yaml(input: &str) -> serde_yaml::Value { + serde_yaml::from_str(input).expect("valid yaml in test fixture") + } + + #[test] + fn test_strip_pagination_prefix_request_and_response() { + assert_eq!(strip_pagination_prefix("$request.cursor"), "cursor"); + assert_eq!( + strip_pagination_prefix("$response.pagination.next_cursor"), + "pagination.next_cursor" + ); + // No prefix: returned verbatim. This matches the upstream importer, + // which is intentionally lenient about callers that already passed + // a dotted path. + assert_eq!(strip_pagination_prefix("plain"), "plain"); + } + + #[test] + fn test_resolve_pagination_cursor_form_strips_prefixes() { + let op = yaml( + r#" +cursor: $request.starting_after +next_cursor: $response.pagination.next +results: $response.data +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("cursor form should produce Some(...)"); + match cfg { + PaginationConfig::Cursor { + cursor, + next_cursor, + results, + } => { + assert_eq!(cursor, "starting_after"); + assert_eq!(next_cursor, "pagination.next"); + assert_eq!(results, "data"); + } + other => panic!("expected Cursor, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_offset_form_with_step_and_has_next_page() { + let op = yaml( + r#" +offset: $request.page +results: $response.users +step: $request.page_size +has-next-page: $response.meta.has_more +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listUsers") + .unwrap() + .expect("offset form should produce Some(...)"); + match cfg { + PaginationConfig::Offset { + offset, + results, + step, + has_next_page, + } => { + assert_eq!(offset, "page"); + assert_eq!(results, "users"); + assert_eq!(step.as_deref(), Some("page_size")); + assert_eq!(has_next_page.as_deref(), Some("meta.has_more")); + } + other => panic!("expected Offset, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_inherits_root_when_op_is_true() { + let root = yaml( + r#" +cursor: $request.cursor +next_cursor: $response.next_cursor +results: $response.items +"#, + ); + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_pagination_extension(Some(&op), Some(&root), "listFoos") + .unwrap() + .expect("true should inherit root config"); + match cfg { + PaginationConfig::Cursor { + cursor, + next_cursor, + results, + } => { + assert_eq!(cursor, "cursor"); + assert_eq!(next_cursor, "next_cursor"); + assert_eq!(results, "items"); + } + other => panic!("expected Cursor, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_op_false_inherits_root_like_upstream() { + // Upstream `getFernPaginationExtension.ts` treats *any* boolean — + // including `false` — as "look up the root extension". Mirror that + // exactly so cli-sdk has parity with the rest of the Fern toolchain. + let root = yaml( + r#" +cursor: $request.cursor +next_cursor: $response.next_cursor +results: $response.items +"#, + ); + let op = serde_yaml::Value::Bool(false); + let cfg = resolve_pagination_extension(Some(&op), Some(&root), "listFoos") + .unwrap() + .expect("false should still resolve via root (upstream parity)"); + assert!(matches!(cfg, PaginationConfig::Cursor { .. })); + } + + #[test] + fn test_resolve_pagination_missing_extension_returns_none() { + let cfg = resolve_pagination_extension(None, None, "listFoos").unwrap(); + assert!(cfg.is_none(), "absent extension → fall back to heuristic"); + } + + #[test] + fn test_resolve_pagination_op_true_without_root_returns_none() { + // Upstream returns `undefined` (no pagination) when the op asks to + // inherit but no root block exists. It does *not* raise. Mirror. + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos").unwrap(); + assert!( + cfg.is_none(), + "true without root → no pagination (upstream parity)" + ); + } + + #[test] + fn test_resolve_pagination_discrimination_order_matches_upstream() { + // Upstream's `convertPaginationExtension` discriminates by checking + // `cursor` first, then `next_uri`, then `next_path`, then `offset`. + // When multiple keys collide we must pick the cursor branch — the + // first one — to stay consistent with how user specs are + // interpreted by the rest of the Fern toolchain. + let op = yaml( + r#" +cursor: $request.cursor +offset: $request.page +results: $response.items +next_cursor: $response.next +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("should resolve to cursor variant"); + assert!( + matches!(cfg, PaginationConfig::Cursor { .. }), + "cursor should win when both `cursor` and `offset` are present" + ); + } + + #[test] + fn test_resolve_pagination_unknown_form_errors() { + // Just `results` — no discriminator. Upstream throws + // `Invalid pagination extension`; we surface a discovery error + // referencing every valid form so the user can debug. + let op = yaml("results: $response.items\n"); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("unknown form should error"); + let msg = format!("{err}"); + assert!(msg.contains("cursor"), "got: {msg}"); + assert!(msg.contains("next_uri"), "got: {msg}"); + assert!(msg.contains("next_path"), "got: {msg}"); + assert!(msg.contains("offset"), "got: {msg}"); + assert!(msg.contains("custom"), "got: {msg}"); + } + + #[test] + fn test_resolve_pagination_non_object_form_errors() { + let op = yaml("- not\n- an\n- object\n"); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("sequence should error"); + assert!( + format!("{err}").contains("expected an object"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_cursor_form_requires_all_fields() { + // `next_cursor` is missing. + let op = yaml( + r#" +cursor: $request.starting_after +results: $response.data +"#, + ); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("missing next_cursor should error"); + assert!( + format!("{err}").contains("next_cursor"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_uri_form() { + let op = yaml( + r#" +next_uri: $response.next +results: $response.items +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("uri form should resolve"); + match cfg { + PaginationConfig::Uri { next_uri, results } => { + assert_eq!(next_uri, "next"); + assert_eq!(results, "items"); + } + other => panic!("expected Uri, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_path_form() { + let op = yaml( + r#" +next_path: $response.links.next +results: $response.entries +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("path form should resolve"); + match cfg { + PaginationConfig::Path { next_path, results } => { + assert_eq!(next_path, "links.next"); + assert_eq!(results, "entries"); + } + other => panic!("expected Path, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_custom_form() { + let op = yaml( + r#" +type: custom +results: $response.items +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("custom form should resolve"); + match cfg { + PaginationConfig::Custom { results } => assert_eq!(results, "items"), + other => panic!("expected Custom, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_custom_form_rejects_unknown_type() { + // `type: anythingElse` is not a valid discriminator, so we fall + // through to the "unknown form" error. + let op = yaml( + r#" +type: nonsense +results: $response.items +"#, + ); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("non-custom `type` should error"); + assert!( + format!("{err}").contains("`type: custom`"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_op_bool_with_root_bool_is_validation_error() { + // Mirrors upstream: when both per-op and root are booleans, raise. + let root = serde_yaml::Value::Bool(true); + let op = serde_yaml::Value::Bool(true); + let err = resolve_pagination_extension(Some(&op), Some(&root), "listFoos") + .expect_err("root-also-bool should error"); + assert!( + format!("{err}").contains("spec-root"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_uri_form_requires_both_fields() { + // `results` is missing. + let op = yaml("next_uri: $response.next\n"); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("missing results should error"); + assert!(format!("{err}").contains("results"), "got: {err}"); + } + + #[test] + fn test_resolve_pagination_offset_form_with_optional_fields() { + let op = yaml( + r#" +offset: $request.page +results: $response.items +step: $request.page_size +has-next-page: $response.has_more +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("offset form should resolve"); + match cfg { + PaginationConfig::Offset { + offset, + results, + step, + has_next_page, + } => { + assert_eq!(offset, "page"); + assert_eq!(results, "items"); + assert_eq!(step.as_deref(), Some("page_size")); + assert_eq!(has_next_page.as_deref(), Some("has_more")); + } + other => panic!("expected Offset, got {other:?}"), + } + } + + // ------------------------------------------------------------------ + // x-fern-availability — operation level + // ------------------------------------------------------------------ + + /// Build a single-operation spec with the given `extra` YAML injected + /// inside the GET operation. Returns the parsed `RestMethod`. + fn parse_op_with_extra(extra: &str) -> RestDescription { + let yaml = format!( + r#" +openapi: "3.0.0" +info: {{ title: T, version: "1.0" }} +servers: [{{ url: "https://x.com" }}] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list +{extra} + responses: {{ "200": {{ description: ok }} }} +"# + ); + load_openapi_spec(&yaml, "t").unwrap() + } + + #[test] + fn test_operation_availability_beta() { + let doc = parse_op_with_extra(" x-fern-availability: beta"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Beta)); + } + + #[test] + fn test_operation_availability_pre_release() { + let doc = parse_op_with_extra(" x-fern-availability: pre-release"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::PreRelease)); + } + + #[test] + fn test_operation_availability_generally_available_canonical() { + let doc = parse_op_with_extra(" x-fern-availability: generally-available"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::GenerallyAvailable)); + } + + #[test] + fn test_operation_availability_alias_ga() { + let doc = parse_op_with_extra(" x-fern-availability: ga"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::GenerallyAvailable)); + } + + #[test] + fn test_operation_availability_alpha() { + let doc = parse_op_with_extra(" x-fern-availability: alpha"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Alpha)); + } + + #[test] + fn test_operation_availability_preview() { + let doc = parse_op_with_extra(" x-fern-availability: preview"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Preview)); + } + + #[test] + fn test_operation_availability_legacy() { + let doc = parse_op_with_extra(" x-fern-availability: legacy"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Legacy)); + } + + /// `stable` is NOT a valid Fern availability — the Fern OpenAPI + /// importer accepts only `ga` (and the canonical + /// `generally-available`). Make sure cli-sdk rejects `stable` for + /// parity, so it can't silently work in one tool and not the other. + #[test] + fn test_operation_availability_stable_is_rejected() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + x-fern-availability: stable + responses: { "200": { description: ok } } +"#; + let err = load_openapi_spec(yaml, "t") + .expect_err("`stable` must NOT be accepted — only `ga` and `generally-available` are"); + let msg = err.to_string(); + assert!( + msg.contains("unknown variant") || msg.contains("variant `stable`"), + "expected serde deser error mentioning the unknown variant `stable`, got: {msg}", + ); + } + + /// Locks in the canonical wire spelling for `pre-release` so the + /// kebab-case rename can't drift. The Fern OpenAPI IR importer + /// collapses `pre-release` into `Beta`; cli-sdk deliberately keeps + /// `PreRelease` distinct (see `Availability` enum docs). + #[test] + fn test_operation_availability_pre_release_wire_spelling() { + let doc = parse_op_with_extra(" x-fern-availability: pre-release"); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.availability, + Some(Availability::PreRelease), + "`pre-release` must deser to its own variant, not collapse to Beta", + ); + assert_eq!(m.availability.unwrap().as_str(), "pre-release"); + assert_eq!(m.availability.unwrap().badge(), Some("[PRE-RELEASE]")); + } + + #[test] + fn test_operation_availability_deprecated_value() { + let doc = parse_op_with_extra(" x-fern-availability: deprecated"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Deprecated)); + } + + #[test] + fn test_operation_availability_absent_defaults_to_none() { + let doc = parse_op_with_extra(""); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, None, "no extension and no deprecated flag → no badge"); + } + + #[test] + fn test_operation_openapi_deprecated_true_falls_back_to_deprecated() { + let doc = parse_op_with_extra(" deprecated: true"); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.availability, + Some(Availability::Deprecated), + "OpenAPI standard `deprecated: true` should lower to Availability::Deprecated when x-fern-availability is absent", + ); + } + + #[test] + fn test_operation_x_fern_availability_overrides_openapi_deprecated() { + // Both set — x-fern-availability wins. + let doc = parse_op_with_extra( + " deprecated: true\n x-fern-availability: beta", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.availability, + Some(Availability::Beta), + "explicit x-fern-availability must override OpenAPI deprecated:true", + ); + } + + // ------------------------------------------------------------------ + // x-fern-availability — parameter level + // ------------------------------------------------------------------ + + #[test] + fn test_parameter_availability_beta() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: legacy_filter + in: query + x-fern-availability: beta + schema: { type: string } + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let p = m.parameters.get("legacy_filter").expect("param missing"); + assert_eq!(p.availability, Some(Availability::Beta)); + } + + #[test] + fn test_parameter_openapi_deprecated_falls_back_to_deprecated_availability() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: legacy_filter + in: query + deprecated: true + schema: { type: string } + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let p = m.parameters.get("legacy_filter").expect("param missing"); + assert_eq!(p.availability, Some(Availability::Deprecated)); + assert!(p.deprecated, "raw deprecated flag is still preserved"); + } + + // ----------------------------------------------------------------------- + // x-fern-base-path + // ----------------------------------------------------------------------- + + /// Spec without `x-fern-base-path` → `RestDescription.base_path` is None. + #[test] + fn test_x_fern_base_path_absent_yields_none() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path, None); + } + + /// Spec with leading-slash `x-fern-base-path` is captured verbatim. + #[test] + fn test_x_fern_base_path_with_leading_slash_captured_verbatim() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: /v1 +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path.as_deref(), Some("/v1")); + } + + /// Spec without a leading slash on `x-fern-base-path` is captured as + /// authored — `build_url` normalizes slashes at request time so the + /// parser does not reshape the user's input. + #[test] + fn test_x_fern_base_path_without_leading_slash_captured_verbatim() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: api/public +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path.as_deref(), Some("api/public")); + } + + /// Empty / whitespace-only `x-fern-base-path` collapses to None so + /// the executor's slash-edge logic doesn't have to handle the empty + /// case. + #[test] + fn test_x_fern_base_path_empty_string_collapses_to_none() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: "" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path, None); + } + + /// `x-fern-base-path` does not affect the command tree — operations + /// are still grouped by `x-fern-sdk-group-name` only, not nested + /// under the base path. + #[test] + fn test_x_fern_base_path_does_not_affect_command_tree() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: /v1 +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + // Resource grouping is unaffected — no `v1` namespace inserted. + assert!(doc.resources.contains_key("things")); + assert!(!doc.resources.contains_key("v1")); + // The operation's stored path is also unchanged — base_path is + // only applied at URL-construction time, not baked into method.path. + let m = &doc.resources["things"].methods["list"]; + assert_eq!(m.path, "/things"); + } + + /// `normalize_base_path` helper: trims surrounding whitespace, treats + /// empty/whitespace-only as absent, otherwise returns the raw value. + /// Direct coverage of the helper independent of YAML parsing. + #[test] + fn test_normalize_base_path() { + assert_eq!(normalize_base_path(None), None); + assert_eq!(normalize_base_path(Some("")), None); + assert_eq!(normalize_base_path(Some(" ")), None); + assert_eq!(normalize_base_path(Some("/v1")), Some("/v1".to_string())); + assert_eq!(normalize_base_path(Some("v1")), Some("v1".to_string())); + assert_eq!(normalize_base_path(Some(" /v1 ")), Some("/v1".to_string())); + } + + // ------------------------------------------------------------------ + // x-fern-sdk-return-value + // + // Mirrors upstream `FernOpenAPIExtension.RESPONSE_PROPERTY` — the + // extension is a string referencing a property on the response body. + // Stored on `RestMethod.return_value` as `Option`, with + // leading/trailing whitespace trimmed and empty/whitespace-only + // values normalized to `None` so downstream code only sees a + // resolvable path or nothing. + // ------------------------------------------------------------------ + + #[test] + fn test_operation_return_value_absent_is_none() { + let doc = parse_op_with_extra(""); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.return_value, None, + "no x-fern-sdk-return-value → return_value is None (executor prints full body)", + ); + } + + #[test] + fn test_operation_return_value_top_level_path() { + let doc = parse_op_with_extra(" x-fern-sdk-return-value: data"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.return_value.as_deref(), Some("data")); + } + + #[test] + fn test_operation_return_value_nested_dotted_path() { + let doc = parse_op_with_extra(" x-fern-sdk-return-value: result.items"); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.return_value.as_deref(), + Some("result.items"), + "dotted paths are preserved verbatim; the executor walks them at runtime", + ); + } + + #[test] + fn test_operation_return_value_empty_string_is_none() { + // Empty / whitespace-only is meaningless for path resolution. + // Normalize to `None` so the executor can't be tripped into + // emitting a confusing "path '' did not resolve" error. + let doc = parse_op_with_extra(" x-fern-sdk-return-value: \"\""); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.return_value, None); + } + + #[test] + fn test_operation_return_value_whitespace_trimmed() { + let doc = parse_op_with_extra(" x-fern-sdk-return-value: \" data \""); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.return_value.as_deref(), + Some("data"), + "surrounding whitespace is trimmed; an inner space would still survive", + ); + } + + // ------------------------------------------------------------------ + // Named-server parsing — `x-fern-server-name` (v2) and `x-name` (v1). + // ------------------------------------------------------------------ + + #[test] + fn test_named_server_v2_spelling_is_parsed() { + // Fern v2 canonical spelling `x-fern-server-name` populates + // `Server.name`. The first server in declaration order remains + // the default — its URL is what drives `RestDescription.root_url` + // for the no-flag case. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-fern-server-name: Production + description: "Production environment" + - url: "https://staging.example.com" + x-fern-server-name: Staging +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 2); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + assert_eq!(doc.servers[0].url, "https://api.example.com"); + assert_eq!( + doc.servers[0].description.as_deref(), + Some("Production environment"), + ); + assert_eq!(doc.servers[1].name.as_deref(), Some("Staging")); + let named: Vec<_> = doc.named_servers().collect(); + assert_eq!(named.len(), 2); + } + + #[test] + fn test_named_server_v1_alias_x_name_is_recognized() { + // Older specs that haven't migrated to `x-fern-server-name` use + // the legacy alias `x-name`. The parser accepts it for + // backwards compatibility. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-name: LegacyProd +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); + } + + #[test] + fn test_named_server_empty_v1_falls_through_to_v2() { + // Defensive parity: an `x-name: ""` on the same entry as a + // valid `x-fern-server-name: Production` must not shadow the + // v2 value. The parser treats empty/whitespace-only extensions + // as "absent" before applying the v1-over-v2 fallback, so a + // blank legacy alias falls through to the canonical Fern + // spelling instead of dropping the server's name entirely. + // Mirrors the existing `test_empty_and_whitespace_server_names_are_dropped` + // guarantee on the v2 side. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-name: "" + x-fern-server-name: Production + - url: "https://whitespace.example.com" + x-name: " " + x-fern-server-name: Staging +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 2); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + assert_eq!(doc.servers[1].name.as_deref(), Some("Staging")); + } + + #[test] + fn test_named_server_empty_v2_still_falls_back_to_v1() { + // Symmetric case: an empty `x-fern-server-name: ""` must not + // suppress a valid `x-name: OldProd` on the same entry. Even + // though v1 wins outright when both are present, this test + // pins the per-field trim+filter behavior so future refactors + // can't regress into the "first field always wins, even when + // blank" trap. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-name: OldProd + x-fern-server-name: "" +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("OldProd")); + } + + #[test] + fn test_named_server_v1_wins_when_both_present() { + // When both v2 (`x-fern-server-name`) and v1 (`x-name`) are + // present on the same entry, v1 wins to mirror fern's + // `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` first-match + // semantics in + // `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`. + // Fern's order is the source of truth — don't flip this even + // if v2-wins reads more naturally. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-fern-server-name: NewName + x-name: OldName +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("OldName")); + } + + #[test] + fn test_no_named_servers_when_extensions_absent() { + // Plain OpenAPI servers without either extension carry no name. + // The CLI surface stays unchanged for these specs — no + // `--server` flag is exposed downstream. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert!(doc.servers[0].name.is_none()); + assert_eq!(doc.named_servers().count(), 0); + } + + #[test] + fn test_per_operation_servers_override_is_captured() { + // Per-operation `servers:` blocks lower into + // `RestMethod.servers` independently of the top-level set, and + // they are authoritative for that operation (the executor + // resolves `--server ` against this list first when it's + // non-empty). + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-fern-server-name: Production +paths: + /uploads: + post: + x-fern-sdk-group-name: ["uploads"] + x-fern-sdk-method-name: create + servers: + - url: "https://upload.example.com" + x-fern-server-name: Upload + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "uploads", "create"); + assert_eq!(m.servers.len(), 1); + assert_eq!(m.servers[0].name.as_deref(), Some("Upload")); + assert_eq!(m.servers[0].url, "https://upload.example.com"); + // Top-level set is preserved separately. + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + } + + #[test] + fn test_empty_and_whitespace_server_names_are_dropped() { + // Empty or whitespace-only `x-fern-server-name` / `x-name` + // values would leak into clap's allowed-list as blank strings + // and into the `Servers:` help block as a blank-named row. The + // parser trims and filters them at the source so downstream + // code never has to defend against this. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://blank.example" + x-fern-server-name: "" + - url: "https://whitespace.example" + x-fern-server-name: " " + - url: "https://blank-legacy.example" + x-name: "" + - url: "https://trimmed.example" + x-fern-server-name: " Production " +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 4, "unnamed entries are still preserved"); + // First three entries' names are filtered out (empty after trim). + assert!(doc.servers[0].name.is_none()); + assert!(doc.servers[1].name.is_none()); + assert!(doc.servers[2].name.is_none()); + // Surrounding whitespace is trimmed. + assert_eq!(doc.servers[3].name.as_deref(), Some("Production")); + // Only the trimmed-but-non-empty entry is selectable via --server. + assert_eq!(doc.named_servers().count(), 1); + } + + // ------------------------------------------------------------------ + // x-fern-enum — per-value overrides on parameter enums + // + // Mirrors the upstream Fern importer + // (packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/ + // extensions/getFernEnum.ts), which models the extension as + // `Record`. cli-sdk + // consumes only `name` (display alias) and `description`; `casing` + // is an SDK-codegen concern. + // ------------------------------------------------------------------ + + fn parse_users_list_user_type(extra_indented: &str) -> MethodParameter { + let yaml = format!( + r#" +openapi: "3.0.0" +info: {{ title: T, version: "1.0" }} +servers: [{{ url: "https://x.com" }}] +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - name: user_type + in: query + schema: + type: string + enum: [all, managed, external] +{extra_indented} + responses: {{ "200": {{ description: ok }} }} +"# + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let m = first_method(&doc, "users", "list"); + m.parameters + .get("user_type") + .expect("user_type param missing") + .clone() + } + + /// Absent extension: `fern_enum` stays `None` and the wire values + /// flow through unchanged. + #[test] + fn test_x_fern_enum_absent_yields_none() { + let p = parse_users_list_user_type(""); + assert!( + p.fern_enum.is_none(), + "no x-fern-enum should produce None, got {:?}", + p.fern_enum + ); + assert_eq!( + p.enum_values.as_deref(), + Some(["all", "managed", "external"].as_slice()) + .map(|s| s.iter().map(|v| v.to_string()).collect::>()) + .as_deref(), + ); + } + + /// Every value carries both `name` and `description`: the parser + /// should preserve each per-value override keyed by the wire value. + #[test] + fn test_x_fern_enum_full_override_round_trips_per_value_fields() { + let p = parse_users_list_user_type( + " x-fern-enum: + all: + name: All + description: Every user, including external collaborators. + managed: + name: Managed + description: Users your enterprise manages. + external: + name: External + description: External collaborators only.", + ); + let map = p.fern_enum.expect("x-fern-enum should be parsed"); + assert_eq!(map.len(), 3, "every enum value should have an entry"); + + let all = map.get("all").expect("`all` entry missing"); + assert_eq!(all.display_name.as_deref(), Some("All")); + assert_eq!( + all.description.as_deref(), + Some("Every user, including external collaborators."), + ); + + let managed = map.get("managed").expect("`managed` entry missing"); + assert_eq!(managed.display_name.as_deref(), Some("Managed")); + assert_eq!( + managed.description.as_deref(), + Some("Users your enterprise manages."), + ); + + let external = map.get("external").expect("`external` entry missing"); + assert_eq!(external.display_name.as_deref(), Some("External")); + assert_eq!( + external.description.as_deref(), + Some("External collaborators only."), + ); + } + + /// Partial override: only some wire values appear under `x-fern-enum`, + /// and listed entries may set only one of `name` / `description`. + /// Missing entries must NOT synthesize blank overrides — they stay + /// out of the map so downstream code falls back to the raw wire + /// value with no description. + #[test] + fn test_x_fern_enum_partial_override_skips_missing_entries() { + let p = parse_users_list_user_type( + " x-fern-enum: + managed: + description: Users your enterprise manages. + external: + name: External", + ); + let map = p.fern_enum.expect("x-fern-enum should be parsed"); + + assert!( + !map.contains_key("all"), + "values absent from x-fern-enum must not appear in the map; got {map:?}", + ); + + let managed = map.get("managed").expect("`managed` entry missing"); + assert_eq!( + managed.display_name, None, + "`managed` set only description; display_name should remain None", + ); + assert_eq!( + managed.description.as_deref(), + Some("Users your enterprise manages."), + ); + + let external = map.get("external").expect("`external` entry missing"); + assert_eq!(external.display_name.as_deref(), Some("External")); + assert_eq!( + external.description, None, + "`external` set only name; description should remain None", + ); + } + + /// Empty / whitespace-only `name` and `description` strings are + /// treated the same as absent, and an entry with both empty fields + /// is dropped entirely. Without this guard, downstream clap rendering + /// would emit empty help strings and a meaningless display alias. + #[test] + fn test_x_fern_enum_drops_empty_entries() { + let p = parse_users_list_user_type( + " x-fern-enum: + all: + name: \"\" + description: \" \" + managed: + name: Managed", + ); + let map = p.fern_enum.expect("x-fern-enum should be parsed"); + assert!( + !map.contains_key("all"), + "entries with only whitespace fields must be dropped, got {map:?}", + ); + assert!(map.contains_key("managed")); + } + + /// `resolve_enum_display_to_wire` is the bridge between the CLI + /// surface (which accepts either display name or wire value) and + /// the HTTP layer (which only ever sees the wire value). This test + /// pins the contract end to end: parser → `MethodParameter` → + /// resolution. + #[test] + fn test_x_fern_enum_display_to_wire_round_trip() { + let p = parse_users_list_user_type( + " x-fern-enum: + all: + name: All + managed: + name: Managed + description: Managed users. + external: {}", + ); + + // Display name → wire value + assert_eq!(p.resolve_enum_display_to_wire("All").as_ref(), "all"); + assert_eq!( + p.resolve_enum_display_to_wire("Managed").as_ref(), + "managed" + ); + + // Wire value passes through untouched + assert_eq!(p.resolve_enum_display_to_wire("all").as_ref(), "all"); + assert_eq!( + p.resolve_enum_display_to_wire("external").as_ref(), + "external", + "value with empty x-fern-enum entry must round-trip as-is", + ); + + // Unknown input is returned unchanged (clap rejects this before + // we ever hit the executor; we only assert non-mutation here). + assert_eq!(p.resolve_enum_display_to_wire("Bogus").as_ref(), "Bogus"); + } + + /// Without `x-fern-enum`, the resolver must be a pure identity — + /// the param-level helper should never block requests on enums + /// that don't opt into the extension. + #[test] + fn test_resolve_enum_display_to_wire_identity_without_fern_enum() { + let p = parse_users_list_user_type(""); + assert!(p.fern_enum.is_none()); + assert_eq!( + p.resolve_enum_display_to_wire("managed").as_ref(), + "managed" + ); + assert_eq!( + p.resolve_enum_display_to_wire("unknown").as_ref(), + "unknown" + ); + } + + // ----------------------------------------------------------------- + // x-fern-sdk-variables / x-fern-sdk-variable + // ----------------------------------------------------------------- + + #[test] + fn test_sdk_variables_parses_string_entries_with_descriptions() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Garden API + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + gardenId: + type: string + description: The garden tenant identifier. + zoneId: + type: string +paths: {} +"#; + let doc = load_openapi_spec(yaml, "garden").unwrap(); + assert_eq!(doc.sdk_variables.len(), 2, "expected two declared variables"); + // Preserves declaration order so --help renders deterministically. + assert_eq!(doc.sdk_variables[0].name, "gardenId"); + assert_eq!(doc.sdk_variables[0].ty, "string"); + assert_eq!( + doc.sdk_variables[0].description.as_deref(), + Some("The garden tenant identifier."), + ); + assert_eq!(doc.sdk_variables[1].name, "zoneId"); + assert_eq!(doc.sdk_variables[1].description, None); + } + + #[test] + fn test_sdk_variables_skips_non_string_types() { + // Fern docs say only strings are supported today. Non-string + // entries are dropped (with a warn-level log); the parser stays + // permissive so downstream behavior degrades to "missing flag" + // rather than a hard load failure. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + count: + type: integer + name: + type: string +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.sdk_variables.len(), 1); + assert_eq!(doc.sdk_variables[0].name, "name"); + } + + #[test] + fn test_sdk_variable_marks_path_parameter() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Garden API + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + gardenId: + type: string +paths: + /gardens/{gardenId}/zones: + get: + operationId: zones-list + x-fern-sdk-group-name: ["zones"] + x-fern-sdk-method-name: list + parameters: + - name: gardenId + in: path + required: true + x-fern-sdk-variable: gardenId + schema: { type: string } + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "garden").unwrap(); + let method = doc + .resources + .get("zones") + .and_then(|r| r.methods.get("list")) + .expect("zones.list missing"); + let param = method + .parameters + .get("gardenId") + .expect("gardenId param missing"); + assert_eq!( + param.variable_reference.as_deref(), + Some("gardenId"), + "path parameter should be marked variable-bound", + ); + } + + #[test] + fn test_sdk_variable_on_non_path_parameter_is_ignored() { + // Fern's IR only honors variable references on `in: path` + // parameters; references on query/header/cookie are logged and + // dropped so the parameter still surfaces as a normal flag. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + tenant: + type: string +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: tenant + in: query + x-fern-sdk-variable: tenant + schema: { type: string } + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let method = doc + .resources + .get("things") + .and_then(|r| r.methods.get("list")) + .expect("things.list missing"); + let param = method.parameters.get("tenant").expect("tenant missing"); + assert!( + param.variable_reference.is_none(), + "x-fern-sdk-variable on a query parameter should NOT mark it variable-bound", + ); + } + + #[test] + fn test_plain_path_param_without_variable_reference() { + // Regression guard: a path parameter without `x-fern-sdk-variable` + // must continue to surface as a normal per-operation flag (no + // accidental variable_reference inheritance). + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /files/{file_id}: + get: + operationId: files-get + x-fern-sdk-group-name: ["files"] + x-fern-sdk-method-name: get + parameters: + - name: file_id + in: path + required: true + schema: { type: string } + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let method = doc + .resources + .get("files") + .and_then(|r| r.methods.get("get")) + .expect("files.get missing"); + let param = method.parameters.get("file_id").expect("file_id missing"); + assert_eq!(param.variable_reference, None); + assert_eq!(param.location.as_deref(), Some("path")); + } + + #[test] + fn test_sdk_variables_absent_yields_empty_vec() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert!(doc.sdk_variables.is_empty()); + } + + // --------------------------------------------------------------------- + // x-fern-streaming parsing + // + // Exercises every form the upstream importer recognizes plus the + // failure modes we explicitly validate. Each test isolates one + // shape so a regression points at the exact branch in + // `parse_streaming_extension`. + // --------------------------------------------------------------------- + + /// Shared helper to parse a spec stub with the given + /// `x-fern-streaming` value and return the resolved streaming + /// config for a single hardcoded operation. + fn streaming_for(extension_yaml: &str) -> Result, CliError> { + let yaml = format!( + r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /stream: + post: + operationId: streamChat + x-fern-streaming: {extension_yaml} + responses: + "200": + description: ok +"# + ); + let doc = load_openapi_spec(&yaml, "stream-spec")?; + Ok(doc + .resources + .get("stream") + .and_then(|r| r.methods.get("stream-chat")) + .and_then(|m| m.streaming.clone())) + } + + #[test] + fn test_streaming_boolean_true_is_ndjson() { + // Upstream's boolean shorthand picks NDJSON (so that callers + // who haven't chosen a wire format don't get SSE semantics). + let result = streaming_for("true").unwrap(); + assert_eq!(result, Some(StreamingConfig::Json { terminator: None })); + } + + #[test] + fn test_streaming_boolean_false_is_none() { + let result = streaming_for("false").unwrap(); + assert_eq!(result, None); + } + + #[test] + fn test_streaming_object_format_sse() { + let result = streaming_for("{ format: sse }").unwrap(); + assert_eq!(result, Some(StreamingConfig::Sse { terminator: None })); + } + + #[test] + fn test_streaming_object_format_json() { + let result = streaming_for("{ format: json }").unwrap(); + assert_eq!(result, Some(StreamingConfig::Json { terminator: None })); + } + + #[test] + fn test_streaming_object_sse_with_terminator() { + let result = streaming_for(r#"{ format: sse, terminator: "[DONE]" }"#).unwrap(); + assert_eq!( + result, + Some(StreamingConfig::Sse { + terminator: Some("[DONE]".to_string()) + }) + ); + } + + #[test] + fn test_streaming_object_default_format_is_json() { + // Matches the typed SDKs (TS / C#) and the upstream importer: + // an object with no `format` field defaults to NDJSON, the + // same as the boolean shorthand. Callers that want SSE must + // declare `format: sse` explicitly. + let result = streaming_for(r#"{ terminator: "[END]" }"#).unwrap(); + assert_eq!( + result, + Some(StreamingConfig::Json { + terminator: Some("[END]".to_string()) + }) + ); + } + + #[test] + fn test_streaming_object_format_text() { + // `format: text` mirrors Fern IR's `TextStreamChunk` variant + // (see `packages/ir-sdk/.../http.yml`). No terminator field + // and no payload type — raw lines are emitted verbatim. + let result = streaming_for("{ format: text }").unwrap(); + assert_eq!(result, Some(StreamingConfig::Text)); + } + + #[test] + fn test_streaming_text_rejects_terminator() { + // `TextStreamChunk` has no `terminator` field; flagging it at + // parse time keeps misconfigurations from silently no-op'ing + // at runtime. + let err = streaming_for(r#"{ format: text, terminator: "EOF" }"#).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("`terminator` is not supported for `format: text`"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_streaming_invalid_format_errors() { + let err = streaming_for("{ format: websocket }").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("`format` must be `sse`, `json`, or `text`"), + "unexpected error: {msg}" + ); + assert!(msg.contains("websocket"), "unexpected error: {msg}"); + } + + #[test] + fn test_streaming_invalid_kind_errors() { + // A scalar that isn't a boolean is meaningless. + let err = streaming_for(r#""sse""#).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("expected a boolean or an object"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_streaming_and_pagination_mutually_exclusive() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /events: + get: + operationId: listEvents + x-fern-streaming: true + x-fern-pagination: + cursor: cursor + next_cursor: $response.next_cursor + results: $response.events + responses: + "200": + description: ok +"#; + let err = load_openapi_spec(yaml, "stream-page").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("`x-fern-streaming`") + && msg.contains("`x-fern-pagination`") + && msg.contains("mutually exclusive"), + "expected mutual-exclusion error, got: {msg}" + ); + } + + // --------------------------------------------------------------- + // `x-fern-retries` resolution + // --------------------------------------------------------------- + + #[test] + fn test_resolve_retries_absent_returns_none() { + // Neither root nor op declared the extension. Operations + // without an explicit policy stay opt-in — the executor + // returns `None` and skips the retry wrapper entirely. + let cfg = resolve_retries_extension(None, None, "getFoo").unwrap(); + assert!(cfg.is_none()); + } + + #[test] + fn test_resolve_retries_op_true_no_root_uses_defaults() { + // `x-fern-retries: true` on an op without a root block + // materializes the cli-sdk runtime defaults (max=2, + // base=250ms, factor=2.0, jitter=0.1) — conservative for an + // interactive CLI where users expect fast, observable failures. + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .expect("op:true materializes defaults"); + assert_eq!(cfg, RetriesConfig::default()); + assert!(cfg.enabled); + assert_eq!(cfg.max_attempts, crate::openapi::discovery::DEFAULT_RETRY_MAX_ATTEMPTS); + assert_eq!(cfg.base_delay_ms, crate::openapi::discovery::DEFAULT_RETRY_BASE_DELAY_MS); + } + + #[test] + fn test_resolve_retries_op_false_disables_regardless_of_root() { + // Per-op `false` short-circuits to `disabled` even when the + // root block enabled retries (op specificity > spec defaults). + let root = yaml("max_attempts: 5\nbase_delay_ms: 1000\n"); + let op = serde_yaml::Value::Bool(false); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("op:false yields explicit disabled config"); + assert!(!cfg.enabled); + assert_eq!(cfg, RetriesConfig::disabled()); + } + + #[test] + fn test_resolve_retries_op_missing_inherits_root_object() { + // Op block missing → inherit the root config verbatim. + let root = yaml("max_attempts: 7\nbase_delay_ms: 250\nfactor: 3.0\njitter: 0.0\n"); + let cfg = resolve_retries_extension(None, Some(&root), "getFoo") + .unwrap() + .expect("missing op inherits root config"); + assert!(cfg.enabled); + assert_eq!(cfg.max_attempts, 7); + assert_eq!(cfg.base_delay_ms, 250); + assert!((cfg.factor - 3.0).abs() < f64::EPSILON); + assert!((cfg.jitter - 0.0).abs() < f64::EPSILON); + } + + #[test] + fn test_resolve_retries_op_true_inherits_root_object() { + // `x-fern-retries: true` on the op should adopt the root + // baseline (not start over from defaults). This is the + // shorthand authors use to opt every endpoint into a spec-wide + // retry policy. + let root = yaml("max_attempts: 5\nbase_delay_ms: 1000\n"); + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("op:true adopts root baseline"); + assert_eq!(cfg.max_attempts, 5); + assert_eq!(cfg.base_delay_ms, 1000); + } + + #[test] + fn test_resolve_retries_op_object_overrides_root_field_by_field() { + // Per-op object merges over the root baseline. Fields the op + // doesn't mention keep the root values; fields it does mention + // override. Matches the pagination resolver's field-by-field + // merge semantics. + let root = yaml("max_attempts: 5\nbase_delay_ms: 1000\nfactor: 2.0\njitter: 0.2\n"); + let op = yaml("max_attempts: 10\n"); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("op object merges over root"); + assert_eq!(cfg.max_attempts, 10, "op overrides root"); + assert_eq!(cfg.base_delay_ms, 1000, "root inherited"); + assert!((cfg.factor - 2.0).abs() < f64::EPSILON); + assert!((cfg.jitter - 0.2).abs() < f64::EPSILON); + } + + #[test] + fn test_resolve_retries_root_disabled_inherited_by_default() { + // Spec-root `{ disabled: true }` should propagate by default + // to operations that don't declare their own block. + let root = yaml("disabled: true\n"); + let cfg = resolve_retries_extension(None, Some(&root), "getFoo") + .unwrap() + .expect("disabled root inherited"); + assert!(!cfg.enabled); + } + + #[test] + fn test_resolve_retries_op_object_reenables_after_root_disabled() { + // An explicit per-op object takes precedence over a disabled + // root. Authors can opt a single endpoint back in even when + // the spec-level policy is off. + let root = yaml("disabled: true\n"); + let op = yaml("max_attempts: 4\n"); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("per-op object re-enables"); + assert!(cfg.enabled); + assert_eq!(cfg.max_attempts, 4); + } + + #[test] + fn test_resolve_retries_upstream_disabled_object() { + // Canonical upstream shape: `{ disabled: true }` per + // `getFernRetriesExtension.ts`. We must round-trip it. + let op = yaml("disabled: true\n"); + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .expect("disabled:true yields explicit disabled"); + assert_eq!(cfg, RetriesConfig::disabled()); + } + + #[test] + fn test_resolve_retries_max_zero_treated_as_disabled() { + // `max_attempts: 0` means "never retry" — equivalent to + // `disabled: true`. Normalize here so the executor doesn't + // have to special-case the count. + let op = yaml("max_attempts: 0\n"); + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .expect("max=0 normalizes to disabled"); + assert!(!cfg.enabled); + } + + #[test] + fn test_resolve_retries_max_attempts_alias_spellings() { + // `max` / `max-attempts` / `max_attempts` are interchangeable + // (forward-compat with upstream which may pick any one). + let op_snake = yaml("max_attempts: 6\n"); + let op_kebab = yaml("max-attempts: 6\n"); + let op_short = yaml("max: 6\n"); + for op in [op_snake, op_kebab, op_short] { + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .unwrap(); + assert_eq!(cfg.max_attempts, 6); + } + } + + #[test] + fn test_resolve_retries_invalid_max_negative_errors() { + // Negative values must be rejected (u32 can't hold them) — + // surface a clear discovery error. + let op = yaml("max_attempts: -1\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("max_attempts"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_factor_below_one_errors() { + // Backoff factor < 1.0 would mean delays shrink, which is + // nonsensical. Reject to catch authoring bugs. + let op = yaml("factor: 0.5\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("factor"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_jitter_out_of_range_errors() { + // Jitter is a fraction in [0, 1]; anything else is an + // authoring bug. + let op = yaml("jitter: 1.5\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("jitter"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_shape_errors() { + // Arrays/strings are not a valid shape. Mirror the + // pagination resolver's strict typing. + let op = yaml("- 1\n- 2\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("x-fern-retries"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_disabled_non_bool_errors() { + // `disabled` must be boolean. Surface authoring bugs early. + let op = yaml("disabled: yes-please\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("disabled"), "{msg}"); + } + + #[test] + fn test_load_openapi_spec_with_root_retries() { + // End-to-end: a spec with a root `x-fern-retries` block and + // no per-op blocks. Every operation inherits the root config. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-retries: + max_attempts: 5 + base_delay_ms: 250 +paths: + /foo: + get: + operationId: getFoo + x-fern-sdk-method-name: get + x-fern-sdk-group-name: foo + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let root_cfg = doc.retries.as_ref().expect("root retries set"); + assert_eq!(root_cfg.max_attempts, 5); + assert_eq!(root_cfg.base_delay_ms, 250); + + let foo = doc.resources.get("foo").expect("foo resource"); + let get = foo + .methods + .values() + .find(|m| m.id.as_deref() == Some("getFoo")) + .expect("getFoo"); + let op_cfg = get.retries.as_ref().expect("op inherited retries"); + assert_eq!(op_cfg.max_attempts, 5); + assert_eq!(op_cfg.base_delay_ms, 250); + } + + // ------------------------------------------------------------------ + // x-fern-audiences (operation level) + // + // Mirrors fern-api/fern's OpenAPI importer + // (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/operation/convertHttpOperation.ts:330`): + // + // audiences: getExtension(operation, FernOpenAPIExtension.AUDIENCES) ?? [] + // + // — i.e. an array-of-strings extension on the operation object, + // defaulting to `[]` when missing. Filtering itself happens at the + // command-tree-build stage (see + // `crate::openapi::commands::filter_doc_by_audiences`), so the + // parser's job is to faithfully surface what the spec declares. + // ------------------------------------------------------------------ + + #[test] + fn test_x_fern_audiences_missing_yields_empty_vec() { + let doc = parse_op_with_extra(""); + let m = first_method(&doc, "things", "list"); + assert!( + m.audiences.is_empty(), + "missing x-fern-audiences should yield empty vec, got: {:?}", + m.audiences + ); + } + + #[test] + fn test_x_fern_audiences_explicit_empty_yields_empty_vec() { + // Mirrors fern: an explicitly empty `x-fern-audiences: []` is + // indistinguishable from "missing" — both lower to `[]` in the IR. + let doc = parse_op_with_extra(" x-fern-audiences: []"); + let m = first_method(&doc, "things", "list"); + assert!(m.audiences.is_empty()); + } + + #[test] + fn test_x_fern_audiences_single_value() { + let doc = parse_op_with_extra( + " x-fern-audiences:\n - public", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.audiences, vec!["public".to_string()]); + } + + #[test] + fn test_x_fern_audiences_multiple_values_preserve_order() { + // fern stores audiences as a `string[]` without dedup or sort + // (`convertHttpOperation.ts:330` is a direct passthrough). We + // do the same — preserve user-declared order so downstream + // consumers can rely on the spec's listing. + let doc = parse_op_with_extra( + " x-fern-audiences:\n - public\n - internal\n - beta", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.audiences, + vec![ + "public".to_string(), + "internal".to_string(), + "beta".to_string(), + ], + ); + } + + #[test] + fn test_x_fern_audiences_preserves_duplicate_entries() { + // Defensive: don't silently dedup. The fern importer passes + // the raw array through, so mirroring that means duplicate + // entries land verbatim in the IR (the audience filter does + // its own membership check and is dedup-tolerant). + let doc = parse_op_with_extra( + " x-fern-audiences:\n - public\n - public", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.audiences, + vec!["public".to_string(), "public".to_string()], + ); + } + // -- JSON Schema composition (oneOf / anyOf / allOf) ----------------- + + #[test] + fn test_composition_one_of_captures_branches() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: integer + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.one_of.len(), 2); + assert_eq!(prop.one_of[0].prop_type.as_deref(), Some("string")); + assert_eq!(prop.one_of[1].prop_type.as_deref(), Some("integer")); + } + + #[test] + fn test_composition_any_of_and_all_of() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r##" + allOf: + - $ref: "#/components/schemas/Base" + - type: object + properties: + extra: + type: string + anyOf: + - type: number + - type: string + "##, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.all_of.len(), 2); + assert_eq!(prop.all_of[0].schema_ref.as_deref(), Some("Base")); + assert_eq!(prop.any_of.len(), 2); + assert_eq!(prop.any_of[0].prop_type.as_deref(), Some("number")); + } + + #[test] + fn test_composition_at_parent_json_schema_level() { + // Component-schema roots can themselves be a oneOf/anyOf/allOf (heavy + // pattern in Auth0's spec). The IR's parent JsonSchema must capture + // these, not just the property-level variants. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r##" + allOf: + - $ref: "#/components/schemas/Base" + - type: object + properties: + extra: + type: string + "##, + ) + .unwrap(); + let s = convert_schema_object(&obj); + assert_eq!(s.all_of.len(), 2); + assert_eq!(s.all_of[0].schema_ref.as_deref(), Some("Base")); + assert_eq!(s.all_of[1].prop_type.as_deref(), Some("object")); + } + + #[test] + fn test_composition_nullable_via_oneof_with_null_type() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: "null" + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.one_of.len(), 2); + assert_eq!(prop.one_of[1].prop_type.as_deref(), Some("null")); + } + + // -- OpenAPI 3.0/3.1 examples ---------------------------------------- + + #[test] + fn test_example_30_single() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + example: "hello" + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!( + prop.example, + Some(serde_yaml::Value::String("hello".to_string())), + ); + assert!(prop.examples.is_none()); + } + + #[test] + fn test_examples_31_list() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + examples: + - "alpha" + - "beta" + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + let seq = prop.examples.as_ref().and_then(|v| v.as_sequence()).unwrap(); + assert_eq!(seq.len(), 2); + assert_eq!(seq[0], serde_yaml::Value::String("alpha".to_string())); + assert_eq!(seq[1], serde_yaml::Value::String("beta".to_string())); + assert!(prop.example.is_none()); + } + + #[test] + fn test_examples_lax_30_map_form() { + // Schema-level `examples` map (out-of-spec for + // OpenAPI 3.0 at the schema level, but real-world specs use it). + // The parser must round-trip without erroring. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: array + examples: + Response: + value: + - red + - green + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + let map = prop.examples.as_ref().and_then(|v| v.as_mapping()).unwrap(); + assert!(map.contains_key(serde_yaml::Value::String("Response".to_string()))); + } + + // -- OpenAPI 3.0/3.1 numeric bounds ---------------------------------- + + #[test] + fn test_bounds_30_inclusive() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + minimum: 0 + maximum: 100 + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.minimum, Some(0.0)); + assert_eq!(prop.maximum, Some(100.0)); + assert_eq!(prop.exclusive_minimum, None); + assert_eq!(prop.exclusive_maximum, None); + } + + #[test] + fn test_bounds_30_exclusive_flag_promotes_minimum() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + minimum: 5 + exclusiveMinimum: true + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.minimum, None, "minimum becomes exclusive in 3.0 flag form"); + assert_eq!(prop.exclusive_minimum, Some(5.0)); + } + + #[test] + fn test_bounds_31_numeric_form() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + exclusiveMinimum: 5 + exclusiveMaximum: 99.5 + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.minimum, None); + assert_eq!(prop.exclusive_minimum, Some(5.0)); + assert_eq!(prop.exclusive_maximum, Some(99.5)); + } + + #[test] + fn test_bounds_30_and_31_produce_same_ir_for_strict_minimum() { + let obj_30: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + minimum: 5 + exclusiveMinimum: true + "#, + ) + .unwrap(); + let obj_31: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + exclusiveMinimum: 5 + "#, + ) + .unwrap(); + let p30 = convert_schema_property(&obj_30); + let p31 = convert_schema_property(&obj_31); + assert_eq!(p30.minimum, p31.minimum); + assert_eq!(p30.exclusive_minimum, p31.exclusive_minimum); + } + + #[test] + fn test_bounds_30_exclusive_maximum_flag_promotes_maximum() { + // Symmetric to test_bounds_30_exclusive_flag_promotes_minimum — locks + // exclusiveMaximum's 3.0 boolean form against the same code path. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + maximum: 99 + exclusiveMaximum: true + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.maximum, None, "maximum becomes exclusive in 3.0 flag form"); + assert_eq!(prop.exclusive_maximum, Some(99.0)); + } + + #[test] + fn test_bounds_30_exclusive_false_keeps_inclusive() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + minimum: 5 + exclusiveMinimum: false + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.minimum, Some(5.0)); + assert_eq!(prop.exclusive_minimum, None); + } + + // -- OpenAPI 3.1 const ------------------------------------------------ + + #[test] + fn test_const_lowers_to_single_element_enum() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + const: webhook.user.created + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!( + prop.enum_values.as_deref(), + Some(&["webhook.user.created".to_string()][..]), + ); + } + + #[test] + fn test_const_numeric_value() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + const: 42 + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.enum_values.as_deref(), Some(&["42".to_string()][..])); + } + + #[test] + fn test_const_lowered_through_flatten_body_params_inline() { + // Inline-property branch: `const` reaches the generated CLI flag as + // (a) a single-value enum constraint, (b) a client-side default + // that auto-injects on omission, and (c) optional even if the + // parent's required: list names it. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + required: [status] + properties: + status: + type: string + const: active + "#, + ) + .unwrap(); + let component_schemas = HashMap::new(); + let params = flatten_body_params(&schema, &component_schemas, 0); + let status = params.get("status").expect("status flag should be emitted"); + assert_eq!(status.enum_values.as_deref(), Some(&["active".to_string()][..])); + assert_eq!(status.default_value, Some(serde_json::Value::String("active".into()))); + assert!(!status.required, "const-bearing flag must be optional"); + } + + #[test] + fn test_const_lowered_through_flatten_body_params_via_ref() { + // $ref-resolution branch: same three properties hold when the const + // lives on a $ref-resolved component schema. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r##" + type: object + required: [role] + properties: + role: + $ref: "#/components/schemas/Role" + "##, + ) + .unwrap(); + let role_schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + const: admin + "#, + ) + .unwrap(); + let mut component_schemas = HashMap::new(); + component_schemas.insert("Role".to_string(), role_schema); + let params = flatten_body_params(&schema, &component_schemas, 0); + let role = params.get("role").expect("role flag should be emitted"); + assert_eq!(role.enum_values.as_deref(), Some(&["admin".to_string()][..])); + assert_eq!(role.default_value, Some(serde_json::Value::String("admin".into()))); + assert!(!role.required, "const-bearing $ref'd flag must be optional"); + } + + #[test] + fn test_const_numeric_default_keeps_wire_type() { + // A numeric const lands on the wire as a JSON number, not a string — + // critical for body fields whose const is meaningful as a literal + // type rather than a label. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + version: + type: integer + const: 2 + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + let version = params.get("version").unwrap(); + assert_eq!( + version.default_value, + Some(serde_json::Value::Number(serde_json::Number::from(2))), + "numeric const must default to JSON number", + ); + } + + #[test] + fn test_const_does_not_override_explicit_enum() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + enum: [a, b] + const: c + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!( + prop.enum_values.as_deref(), + Some(&["a".to_string(), "b".to_string()][..]), + ); + } + + // -- OpenAPI 3.1 webhooks --------------------------------------------- + + #[test] + fn test_webhooks_block_parses_and_is_ignored_for_commands() { + let yaml = r##" +openapi: "3.1.0" +info: + title: Webhook-only spec + version: "1.0.0" +paths: {} +webhooks: + userCreated: + post: + operationId: handleUserCreated + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "200": + description: OK +components: + schemas: + User: + type: object + properties: + id: { type: string } +"##; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let desc = load_openapi_spec_from_value(value, "test-cli").expect("spec should parse"); + // Component schema is still reachable via discovery. + assert!(desc.schemas.contains_key("User")); + // No CLI methods generated. + let total_methods: usize = desc.resources.values().map(|r| r.methods.len()).sum(); + assert_eq!(total_methods, 0, "webhook ops must not become subcommands"); + } + + // -- OpenAPI 3.1 nullability ------------------------------------------ + + #[test] + fn test_nullable_30_explicit_field() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + nullable: true + "#, + ) + .unwrap(); + assert_eq!(obj.schema_type(), Some("string")); + assert!(obj.is_nullable()); + let prop = convert_schema_property(&obj); + assert!(prop.nullable); + assert_eq!(prop.prop_type.as_deref(), Some("string")); + } + + #[test] + fn test_nullable_31_type_array_with_null() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: ["string", "null"] + "#, + ) + .unwrap(); + assert_eq!(obj.schema_type(), Some("string")); + assert!(obj.is_nullable()); + let prop = convert_schema_property(&obj); + assert!(prop.nullable); + assert_eq!(prop.prop_type.as_deref(), Some("string")); + } + + #[test] + fn test_nullable_31_type_array_null_first() { + // Order shouldn't matter — `find` picks first non-null, presence of + // "null" anywhere flips nullability on. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: ["null", "integer"] + "#, + ) + .unwrap(); + assert_eq!(obj.schema_type(), Some("integer")); + assert!(obj.is_nullable()); + } + + #[test] + fn test_nullable_31_type_array_only_null() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: ["null"] + "#, + ) + .unwrap(); + assert_eq!(obj.schema_type(), None); + assert!(obj.is_nullable()); + } + + #[test] + fn test_nullable_30_regression_plain_type() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + "#, + ) + .unwrap(); + assert_eq!(obj.schema_type(), Some("string")); + assert!(!obj.is_nullable()); + let prop = convert_schema_property(&obj); + assert!(!prop.nullable); + } + + #[test] + fn test_nullable_at_parent_json_schema_level() { + // The parent JsonSchema (returned by convert_schema_object) carries + // its own nullable flag — covers the case where a top-level + // request/response body schema is itself nullable rather than just + // having nullable properties. + let obj_30: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + nullable: true + "#, + ) + .unwrap(); + let obj_31: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: ["object", "null"] + "#, + ) + .unwrap(); + let s_30 = convert_schema_object(&obj_30); + let s_31 = convert_schema_object(&obj_31); + assert!(s_30.nullable); + assert!(s_31.nullable); + assert_eq!(s_30.schema_type.as_deref(), Some("object")); + assert_eq!(s_31.schema_type.as_deref(), Some("object")); + } + + #[test] + fn test_nullable_schema_object_lowering() { + let obj_30: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + email: + type: string + nullable: true + "#, + ) + .unwrap(); + let obj_31: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + email: + type: ["string", "null"] + "#, + ) + .unwrap(); + let lowered_30 = convert_schema_object(&obj_30); + let lowered_31 = convert_schema_object(&obj_31); + assert_eq!(lowered_30.schema_type.as_deref(), Some("object")); + assert_eq!(lowered_31.schema_type.as_deref(), Some("object")); + assert!(lowered_30.properties["email"].nullable); + assert!(lowered_31.properties["email"].nullable); + assert_eq!( + lowered_30.properties["email"].prop_type.as_deref(), + Some("string"), + ); + assert_eq!( + lowered_31.properties["email"].prop_type.as_deref(), + Some("string"), + ); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! Deterministic SKILL.md generator for OpenAPI-driven CLIs. +//! +//! Walks the parsed [`RestDescription`] and emits one markdown file per +//! top-level command group plus a shared file containing auth setup and +//! global flags. All output is fully deterministic — pure Rust string +//! templates over spec data, no LLM, no hand-written overlay files. +//! +//! Public surface: [`generate_skills`] — a pure function returning +//! `(PathBuf, String)` pairs. The caller is responsible for filesystem +//! writes. + +use std::fmt::Write as FmtWrite; +use std::path::PathBuf; + +use clap::{Arg, Command}; + +use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; +use crate::text; + +/// Maximum characters for the frontmatter `description` field. +const FRONTMATTER_DESC_LIMIT: usize = 120; + +/// Returns the clap `Command` for `generate-skills` so it appears in +/// `--help`, shell completions, and man pages. +pub fn generate_skills_command() -> Command { + Command::new("generate-skills") + .about("Generate SKILL.md files for AI agent integration") + .arg( + Arg::new("output-dir") + .long("output-dir") + .value_name("PATH") + .help("Output directory [default: skills]"), + ) +} + +/// Generates all SKILL.md files for the given binary. +/// +/// Returns a list of `(relative_path, content)` pairs. The caller writes +/// them under whatever output directory was requested. +pub fn generate_skills( + doc: &RestDescription, + bin_name: &str, + auth_bindings: &[(String, SchemeBinding)], +) -> Vec<(PathBuf, String)> { + let mut files: Vec<(PathBuf, String)> = Vec::new(); + + // Shared skill + let shared_path = PathBuf::from(format!("{bin_name}-shared")).join("SKILL.md"); + let shared_content = render_shared_skill(doc, bin_name, auth_bindings); + files.push((shared_path, shared_content)); + + // Per-group skills — sorted for deterministic output + let mut group_names: Vec<&String> = doc.resources.keys().collect(); + group_names.sort(); + for group_name in group_names { + let resource = &doc.resources[group_name]; + let group_path = PathBuf::from(format!("{bin_name}-{group_name}")).join("SKILL.md"); + let group_content = render_group_skill(doc, bin_name, group_name, resource); + files.push((group_path, group_content)); + } + + files +} + +// --------------------------------------------------------------------------- +// Shared skill +// --------------------------------------------------------------------------- + +fn render_shared_skill( + doc: &RestDescription, + bin_name: &str, + auth_bindings: &[(String, SchemeBinding)], +) -> String { + let mut out = String::new(); + + // Frontmatter + let desc = format!( + "{bin_name} CLI: Shared patterns for authentication, global flags, and output formatting." + ); + write_frontmatter(&mut out, &format!("{bin_name}-shared"), &desc); + + // Title + let _ = writeln!(out, "# {bin_name} — Shared Reference\n"); + + // Auth section + let _ = writeln!(out, "## Authentication\n"); + if auth_bindings.is_empty() && doc.security_schemes.is_empty() { + let _ = writeln!(out, "No authentication configured.\n"); + } else { + render_auth_section(&mut out, doc, bin_name, auth_bindings); + } + + // Global flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\n"); + let _ = writeln!(out, "| Flag | Description | Default |"); + let _ = writeln!(out, "|------|-------------|---------|"); + let _ = writeln!( + out, + "| `--dry-run` | Validate locally without sending the request | |" + ); + let _ = writeln!( + out, + "| `--format ` | Output format: `json`, `table`, `yaml`, `csv` | `json` |" + ); + let _ = writeln!( + out, + "| `--base-url ` | Override the API base URL | |" + ); + let _ = writeln!( + out, + "| `--params ` | URL/query/path parameters as JSON | |" + ); + let _ = writeln!( + out, + "| `--json ` | Request body for POST/PATCH/PUT | |" + ); + let _ = writeln!( + out, + "| `-o, --output ` | Write binary responses to a file | |" + ); + let _ = writeln!( + out, + "| `--page-all` | Auto-paginate (NDJSON) | off |" + ); + let _ = writeln!( + out, + "| `--page-limit ` | Max pages to fetch | `10` |" + ); + let _ = writeln!( + out, + "| `--page-delay ` | Delay between page fetches | `100` |" + ); + let _ = writeln!( + out, + "| `--no-retry` | Disable retries | |" + ); + let _ = writeln!( + out, + "| `--no-extract` | Print the full response body | |" + ); + let _ = writeln!(out); + + // Output formatting tips + let _ = writeln!(out, "## Output Formatting\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# JSON (default)"); + let _ = writeln!(out, "{bin_name} --format json\n"); + let _ = writeln!(out, "# Table view"); + let _ = writeln!(out, "{bin_name} --format table\n"); + let _ = writeln!(out, "# Pipe-friendly: jq, grep, etc."); + let _ = writeln!( + out, + "{bin_name} | jq '.fieldName'" + ); + let _ = writeln!(out, "```\n"); + + // Dry-run section + let _ = writeln!(out, "## Dry Run\n"); + let _ = writeln!( + out, + "Use `--dry-run` to preview the HTTP request without sending it:\n" + ); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "{bin_name} --dry-run"); + let _ = writeln!(out, "```\n"); + + out +} + +fn render_auth_section( + out: &mut String, + doc: &RestDescription, + bin_name: &str, + auth_bindings: &[(String, SchemeBinding)], +) { + if !auth_bindings.is_empty() { + for (scheme_name, binding) in auth_bindings { + let scheme_type = doc + .security_schemes + .get(scheme_name) + .map(describe_scheme_type) + .unwrap_or_else(|| "bearer".to_string()); + + let source_desc = describe_binding_source(binding); + let _ = writeln!( + out, + "- **{scheme_name}** ({scheme_type}): {source_desc}" + ); + } + let _ = writeln!(out); + + // Emit setup instructions based on binding sources + let env_vars = collect_env_vars(auth_bindings); + if !env_vars.is_empty() { + let _ = writeln!(out, "Set the required environment variable(s):\n"); + let _ = writeln!(out, "```bash"); + for var in &env_vars { + let _ = writeln!(out, "export {var}=\"\""); + } + let _ = writeln!(out, "```\n"); + + let _ = writeln!(out, "Verify authentication works:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "{bin_name} --help"); + let _ = writeln!(out, "```\n"); + } + } else { + // Fall back to security schemes from spec + let mut schemes: Vec<(&String, &SecurityScheme)> = doc.security_schemes.iter().collect(); + schemes.sort_by_key(|(name, _)| *name); + for (name, scheme) in &schemes { + let _ = writeln!(out, "- **{name}** ({})", describe_scheme_type(scheme)); + } + let _ = writeln!(out); + } +} + +fn describe_scheme_type(scheme: &SecurityScheme) -> String { + match scheme { + SecurityScheme::HttpBearer => "bearer token".to_string(), + SecurityScheme::HttpBasic => "HTTP basic auth".to_string(), + SecurityScheme::ApiKeyHeader { name } => format!("API key in `{name}` header"), + SecurityScheme::ApiKeyQuery { name } => format!("API key in `{name}` query param"), + SecurityScheme::OAuth2 => "OAuth2 bearer token".to_string(), + SecurityScheme::Other(ty) => ty.clone(), + } +} + +fn describe_binding_source(binding: &SchemeBinding) -> String { + match binding { + SchemeBinding::Token(src) => describe_credential_source(src), + SchemeBinding::Basic { username, password } => { + format!( + "HTTP basic — username: {}, password: {}", + describe_credential_source(username), + describe_credential_source(password), + ) + } + SchemeBinding::Custom(_) => "custom auth provider".to_string(), + } +} + +fn describe_credential_source(src: &AuthCredentialSource) -> String { + match src { + AuthCredentialSource::Env(name) => format!("`{name}` env var"), + AuthCredentialSource::Cli(arg) => format!("`--{arg}` flag"), + AuthCredentialSource::File(path) => format!("`{}` file", path.display()), + AuthCredentialSource::Literal(_) => "built-in literal".to_string(), + AuthCredentialSource::Closure(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + AuthCredentialSource::Missing => "(unbound)".to_string(), + } +} + +fn collect_env_vars(bindings: &[(String, SchemeBinding)]) -> Vec { + let mut vars = Vec::new(); + for (_, binding) in bindings { + collect_env_vars_from_binding(binding, &mut vars); + } + vars +} + +fn collect_env_vars_from_binding(binding: &SchemeBinding, out: &mut Vec) { + match binding { + SchemeBinding::Token(src) => collect_env_vars_from_source(src, out), + SchemeBinding::Basic { username, password } => { + collect_env_vars_from_source(username, out); + collect_env_vars_from_source(password, out); + } + SchemeBinding::Custom(_) => {} + } +} + +fn collect_env_vars_from_source(src: &AuthCredentialSource, out: &mut Vec) { + match src { + AuthCredentialSource::Env(name) if !out.contains(name) => { + out.push(name.clone()); + } + AuthCredentialSource::Chain(sources) => { + for s in sources { + collect_env_vars_from_source(s, out); + } + } + _ => {} + } +} + +// --------------------------------------------------------------------------- +// Per-group skill +// --------------------------------------------------------------------------- + +fn render_group_skill( + doc: &RestDescription, + bin_name: &str, + group_name: &str, + resource: &RestResource, +) -> String { + let mut out = String::new(); + + // Frontmatter + let skill_name = format!("{bin_name}-{group_name}"); + let group_desc = group_description(doc, group_name); + let frontmatter_desc = text::truncate_description(&group_desc, FRONTMATTER_DESC_LIMIT, true); + write_frontmatter(&mut out, &skill_name, &frontmatter_desc); + + // Title + let _ = writeln!(out, "# {group_name}\n"); + + // Prerequisite + let _ = writeln!( + out, + "> **PREREQUISITE:** Read `../{bin_name}-shared/SKILL.md` for auth, \ + global flags, and output formatting. If missing, run \ + `{bin_name} generate-skills` to create it.\n" + ); + + // Syntax + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "{bin_name} {group_name} [flags]"); + let _ = writeln!(out, "```\n"); + + // API Resources tree + let _ = writeln!(out, "## API Resources\n"); + render_resource_tree(&mut out, resource, 0); + + // Discovering Commands + let _ = writeln!(out, "## Discovering Commands\n"); + let _ = writeln!(out, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + let _ = writeln!(out, "```\n"); + + out +} + +fn group_description(doc: &RestDescription, group_name: &str) -> String { + // Try x-fern-groups metadata first + if let Some(info) = doc.groups.get(group_name) { + if let Some(ref summary) = info.summary { + return summary.clone(); + } + if let Some(ref description) = info.description { + return first_sentence(description); + } + } + + // Fall back to spec title/description + if let Some(ref title) = doc.title { + return format!("{title}: Operations on {group_name}"); + } + format!("Operations on {group_name}") +} + +fn first_sentence(s: &str) -> String { + if let Some(idx) = s.find(". ") { + s[..=idx].to_string() + } else { + s.to_string() + } +} + +fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { + // Render methods at this level — sorted + let mut method_names: Vec<&String> = resource.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let method = &resource.methods[method_name]; + let desc = method + .description + .as_deref() + .map(|d| text::truncate_description(d, text::CLI_DESCRIPTION_LIMIT, false)) + .unwrap_or_default(); + if desc.is_empty() { + let _ = writeln!(out, " - `{method_name}`"); + } else { + let _ = writeln!(out, " - `{method_name}` — {desc}"); + } + } + + // Render sub-resources — sorted, with heading + let mut sub_names: Vec<&String> = resource.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let sub = &resource.resources[sub_name]; + let heading_level = "#".repeat((3 + depth).min(6)); + let _ = writeln!(out, "\n{heading_level} {sub_name}\n"); + render_resource_tree(out, sub, depth + 1); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn write_frontmatter(out: &mut String, name: &str, description: &str) { + let _ = writeln!(out, "---"); + let _ = writeln!(out, "name: \"{}\"", escape_yaml_string(name)); + let _ = writeln!(out, "description: \"{}\"", escape_yaml_string(description)); + let _ = writeln!(out, "---\n"); +} + +fn escape_yaml_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + +/// Placeholder value for a method parameter, derived from format or type. +pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) -> String { + // Check format first + if let Some(ref fmt) = param.format { + match fmt.as_str() { + "email" => return "user@example.com".to_string(), + "uri" | "url" => return "https://example.com".to_string(), + "uuid" => return "".to_string(), + "date" => return "2024-01-01".to_string(), + "date-time" => return "2024-01-01T00:00:00Z".to_string(), + "int32" | "int64" => return "42".to_string(), + "float" | "double" => return "3.14".to_string(), + _ => {} + } + } + + // Fall back to type + match param.param_type.as_deref() { + Some("integer") => "42".to_string(), + Some("number") => "3.14".to_string(), + Some("boolean") => "true".to_string(), + Some("array") => "[]".to_string(), + Some("object") => "{}".to_string(), + _ => "".to_string(), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + + fn minimal_doc() -> RestDescription { + let mut resources = HashMap::new(); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + description: Some("List all items.".to_string()), + http_method: "GET".to_string(), + path: "/items".to_string(), + ..Default::default() + }, + ); + methods.insert( + "get".to_string(), + RestMethod { + description: Some("Get a single item by ID.".to_string()), + http_method: "GET".to_string(), + path: "/items/{id}".to_string(), + ..Default::default() + }, + ); + resources.insert( + "items".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "test-api".to_string(), + title: Some("Test API".to_string()), + resources, + ..Default::default() + } + } + + fn bindings_for(env_var: &str) -> Vec<(String, SchemeBinding)> { + vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::Env(env_var.to_string())), + )] + } + + #[test] + fn generates_shared_and_group_files() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &bindings_for("TEST_API_KEY")); + let names: Vec = files.iter().map(|(p, _)| p.display().to_string()).collect(); + assert!(names.contains(&"testcli-shared/SKILL.md".to_string())); + assert!(names.contains(&"testcli-items/SKILL.md".to_string())); + assert_eq!(files.len(), 2); + } + + #[test] + fn shared_skill_has_valid_frontmatter() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &bindings_for("TEST_API_KEY")); + let shared = &files[0].1; + assert!(shared.starts_with("---\n")); + assert!(shared.contains("name: \"testcli-shared\"")); + assert!(shared.contains("description: \"")); + // Verify closing frontmatter + let second_fence = shared[4..].find("---").unwrap() + 4; + assert!(second_fence > 4); + } + + #[test] + fn group_skill_has_valid_frontmatter() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &bindings_for("TEST_API_KEY")); + let group = &files[1].1; + assert!(group.starts_with("---\n")); + assert!(group.contains("name: \"testcli-items\"")); + assert!(group.contains("description: \"")); + } + + #[test] + fn shared_skill_contains_auth_section() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &bindings_for("TEST_API_KEY")); + let shared = &files[0].1; + assert!(shared.contains("## Authentication")); + assert!(shared.contains("TEST_API_KEY")); + assert!(shared.contains("bearerAuth")); + } + + #[test] + fn shared_skill_contains_global_flags() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &[]); + let shared = &files[0].1; + assert!(shared.contains("## Global Flags")); + assert!(shared.contains("--dry-run")); + assert!(shared.contains("--format")); + assert!(shared.contains("--page-all")); + } + + #[test] + fn group_skill_lists_methods() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &[]); + let group = &files[1].1; + assert!(group.contains("`get`")); + assert!(group.contains("`list`")); + assert!(group.contains("List all items.")); + } + + #[test] + fn group_skill_has_prerequisite_link() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &[]); + let group = &files[1].1; + assert!(group.contains("testcli-shared/SKILL.md")); + assert!(group.contains("testcli generate-skills")); + } + + #[test] + fn group_skill_has_discovering_commands() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &[]); + let group = &files[1].1; + assert!(group.contains("## Discovering Commands")); + assert!(group.contains("testcli items --help")); + assert!(group.contains("--help --format json")); + } + + #[test] + fn example_placeholder_format_driven() { + let email_param = MethodParameter { + format: Some("email".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&email_param), "user@example.com"); + + let uuid_param = MethodParameter { + format: Some("uuid".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&uuid_param), ""); + + let int_param = MethodParameter { + format: Some("int64".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&int_param), "42"); + } + + #[test] + fn example_placeholder_type_driven() { + let int_param = MethodParameter { + param_type: Some("integer".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&int_param), "42"); + + let bool_param = MethodParameter { + param_type: Some("boolean".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&bool_param), "true"); + + let string_param = MethodParameter { + param_type: Some("string".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&string_param), ""); + } + + #[test] + fn example_placeholder_missing_fields() { + let empty = MethodParameter::default(); + assert_eq!(example_placeholder(&empty), ""); + } + + #[test] + fn multi_level_resource_nesting() { + let mut inner_methods = HashMap::new(); + inner_methods.insert( + "read".to_string(), + RestMethod { + description: Some("Read nested item.".to_string()), + ..Default::default() + }, + ); + + let mut sub_resources = HashMap::new(); + sub_resources.insert( + "nested".to_string(), + RestResource { + methods: inner_methods, + resources: HashMap::new(), + }, + ); + + let mut top_methods = HashMap::new(); + top_methods.insert( + "list".to_string(), + RestMethod { + description: Some("List things.".to_string()), + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods: top_methods, + resources: sub_resources, + }, + ); + + let doc = RestDescription { + name: "api".to_string(), + resources, + ..Default::default() + }; + + let files = generate_skills(&doc, "cli", &[]); + let group = &files[1].1; + assert!(group.contains("`list`")); + assert!(group.contains("### nested")); + assert!(group.contains("`read`")); + } + + #[test] + fn empty_resources_produces_only_shared() { + let doc = RestDescription { + name: "empty".to_string(), + ..Default::default() + }; + let files = generate_skills(&doc, "empty", &[]); + assert_eq!(files.len(), 1); + assert!(files[0].0.display().to_string().contains("shared")); + } + + #[test] + fn deterministic_output_across_calls() { + let doc = minimal_doc(); + let bindings = bindings_for("KEY"); + let a = generate_skills(&doc, "test", &bindings); + let b = generate_skills(&doc, "test", &bindings); + assert_eq!(a.len(), b.len()); + for (fa, fb) in a.iter().zip(b.iter()) { + assert_eq!(fa.0, fb.0); + assert_eq!(fa.1, fb.1); + } + } + + #[test] + fn frontmatter_description_escapes_quotes() { + let mut resources = HashMap::new(); + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + RestMethod::default(), + ); + resources.insert( + "test".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "api".to_string(), + title: Some("API with \"quotes\"".to_string()), + resources, + ..Default::default() + }; + let files = generate_skills(&doc, "cli", &[]); + let group = &files[1].1; + assert!(group.contains("\\\"quotes\\\"")); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/output.rs b/seed/cli/query-parameters-openapi/github-npm/src/output.rs new file mode 100644 index 000000000000..6ae0f1bea2a1 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/output.rs @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared output helpers for terminal sanitization, coloring, and stderr +//! messaging. +//! +//! Every function that prints untrusted content to the terminal should use +//! these helpers to prevent escape-sequence injection, Unicode spoofing, +//! and to respect `NO_COLOR` / non-TTY environments. + +use crate::error::CliError; + +// ── Dangerous character detection ───────────────────────────────────── + +/// Returns `true` for Unicode characters that are dangerous in terminal +/// output but not caught by `char::is_control()`: zero-width chars, bidi +/// overrides, Unicode line/paragraph separators, and directional isolates. +/// +/// Using `matches!` with char ranges gives O(1) per character instead of the +/// O(M) linear scan that a slice `.contains()` would require. +pub(crate) fn is_dangerous_unicode(c: char) -> bool { + matches!(c, + // zero-width: ZWSP, ZWNJ, ZWJ, BOM/ZWNBSP + '\u{200B}'..='\u{200D}' | '\u{FEFF}' | + // bidi: LRE, RLE, PDF, LRO, RLO + '\u{202A}'..='\u{202E}' | + // line / paragraph separators + '\u{2028}'..='\u{2029}' | + // directional isolates: LRI, RLI, FSI, PDI + '\u{2066}'..='\u{2069}' + ) +} + +// ── Sanitization ────────────────────────────────────────────────────── + +/// Strip dangerous characters from untrusted text before printing to the +/// terminal. Removes ASCII control characters (except `\n` and `\t`, +/// which are preserved for readability) and dangerous Unicode characters +/// (bidi overrides, zero-width chars, line/paragraph separators). +pub(crate) fn sanitize_for_terminal(text: &str) -> String { + text.chars() + .filter(|&c| { + if c == '\n' || c == '\t' { + return true; + } + if c.is_control() { + return false; + } + !is_dangerous_unicode(c) + }) + .collect() +} + +/// Rejects strings containing control characters (C0: U+0000–U+001F, +/// C1: U+0080–U+009F, and DEL: U+007F) or dangerous Unicode characters +/// such as zero-width chars, bidi overrides, and line/paragraph separators. +/// +/// Used for validating CLI argument values at the parse boundary. +pub(crate) fn reject_dangerous_chars(value: &str, flag_name: &str) -> Result<(), CliError> { + for c in value.chars() { + if c.is_control() { + return Err(CliError::Validation(format!( + "{flag_name} contains invalid control characters" + ))); + } + if is_dangerous_unicode(c) { + return Err(CliError::Validation(format!( + "{flag_name} contains invalid Unicode characters" + ))); + } + } + Ok(()) +} + +// ── Color ───────────────────────────────────────────────────────────── + +/// Returns true when stderr is connected to an interactive terminal and +/// `NO_COLOR` is not set, meaning ANSI color codes will be visible. +pub(crate) fn stderr_supports_color() -> bool { + use std::io::IsTerminal; + std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +/// Wrap `text` in ANSI bold + the given color code, resetting afterwards. +/// Returns the plain text unchanged when stderr is not a TTY or `NO_COLOR` +/// is set. +pub(crate) fn colorize(text: &str, ansi_color: &str) -> String { + if stderr_supports_color() && ansi_color.chars().all(|c| c.is_ascii_digit()) { + format!("\x1b[1;{ansi_color}m{text}\x1b[0m") + } else { + text.to_string() + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + // ── sanitize_for_terminal ───────────────────────────────────── + + #[test] + fn sanitize_strips_ansi_escape_sequences() { + let input = "normal \x1b[31mred text\x1b[0m end"; + let sanitized = sanitize_for_terminal(input); + assert_eq!(sanitized, "normal [31mred text[0m end"); + assert!(!sanitized.contains('\x1b')); + } + + #[test] + fn sanitize_preserves_newlines_and_tabs() { + let input = "line1\nline2\ttab"; + assert_eq!(sanitize_for_terminal(input), "line1\nline2\ttab"); + } + + #[test] + fn sanitize_strips_bell_and_backspace() { + let input = "hello\x07bell\x08backspace"; + assert_eq!(sanitize_for_terminal(input), "hellobellbackspace"); + } + + #[test] + fn sanitize_strips_carriage_return() { + let input = "real\rfake"; + assert_eq!(sanitize_for_terminal(input), "realfake"); + } + + #[test] + fn sanitize_strips_bidi_overrides() { + let input = "hello\u{202E}dlrow"; + assert_eq!(sanitize_for_terminal(input), "hellodlrow"); + } + + #[test] + fn sanitize_strips_zero_width_chars() { + assert_eq!(sanitize_for_terminal("foo\u{200B}bar"), "foobar"); + assert_eq!(sanitize_for_terminal("foo\u{FEFF}bar"), "foobar"); + } + + #[test] + fn sanitize_strips_line_separators() { + assert_eq!(sanitize_for_terminal("line1\u{2028}line2"), "line1line2"); + assert_eq!(sanitize_for_terminal("para1\u{2029}para2"), "para1para2"); + } + + #[test] + fn sanitize_strips_directional_isolates() { + assert_eq!(sanitize_for_terminal("a\u{2066}b\u{2069}c"), "abc"); + } + + #[test] + fn sanitize_preserves_normal_unicode() { + assert_eq!(sanitize_for_terminal("日本語 café αβγ"), "日本語 café αβγ"); + } + + // ── reject_dangerous_chars ──────────────────────────────────── + + #[test] + fn reject_clean_string() { + assert!(reject_dangerous_chars("hello/world", "test").is_ok()); + } + + #[test] + fn reject_tab() { + assert!(reject_dangerous_chars("hello\tworld", "test").is_err()); + } + + #[test] + fn reject_newline() { + assert!(reject_dangerous_chars("hello\nworld", "test").is_err()); + } + + #[test] + fn reject_del() { + assert!(reject_dangerous_chars("hello\x7Fworld", "test").is_err()); + } + + #[test] + fn reject_zero_width_space() { + assert!(reject_dangerous_chars("foo\u{200B}bar", "test").is_err()); + } + + #[test] + fn reject_bom() { + assert!(reject_dangerous_chars("foo\u{FEFF}bar", "test").is_err()); + } + + #[test] + fn reject_rtl_override() { + assert!(reject_dangerous_chars("foo\u{202E}bar", "test").is_err()); + } + + #[test] + fn reject_line_separator() { + assert!(reject_dangerous_chars("foo\u{2028}bar", "test").is_err()); + } + + #[test] + fn reject_paragraph_separator() { + assert!(reject_dangerous_chars("foo\u{2029}bar", "test").is_err()); + } + + #[test] + fn reject_zero_width_joiner() { + assert!(reject_dangerous_chars("foo\u{200D}bar", "test").is_err()); + } + + #[test] + fn reject_preserves_normal_unicode() { + assert!(reject_dangerous_chars("日本語", "test").is_ok()); + assert!(reject_dangerous_chars("café", "test").is_ok()); + assert!(reject_dangerous_chars("αβγ", "test").is_ok()); + } + + #[test] + fn reject_c1_control_csi() { + // U+009B is the C1 "Control Sequence Introducer" — can inject + // terminal escape sequences just like ESC+[ + assert!(reject_dangerous_chars("foo\u{009B}bar", "test").is_err()); + } + + // ── colorize ────────────────────────────────────────────────── + + #[test] + fn colorize_returns_text_in_no_color_mode() { + // In test environment, stderr is typically not a TTY + let result = colorize("hello", "31"); + // Either plain text (no TTY) or colored (TTY) — we just verify + // it contains the original text + assert!(result.contains("hello")); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/stability.rs b/seed/cli/query-parameters-openapi/github-npm/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/stability.rs @@ -0,0 +1,127 @@ +//! Stability levels for commands in the CLI tree. +//! +//! Commands can be annotated with a [`Stability`] level. Pre-GA commands +//! are hidden from `--help` and gated behind `--maturity `. + +/// Stability level for a command or command group. +/// +/// Ordered most-mature → least: `Stable > Rc > Beta > Alpha > EarlyAccess`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Stability { + Stable, + Rc, + Beta, + Alpha, + EarlyAccess, + Deprecated { + message: String, + replacement: Option, + removed_in: Option, + }, + Removed { + message: String, + }, +} + +impl Stability { + /// Numeric rank for maturity comparison. Lower = more mature. + /// `Deprecated` and `Removed` are special — they are always visible + /// (with a badge) and don't participate in maturity gating. + pub fn rank(&self) -> u8 { + match self { + Self::Stable => 0, + Self::Rc => 1, + Self::Beta => 2, + Self::Alpha => 3, + Self::EarlyAccess => 4, + Self::Deprecated { .. } => 0, // always visible + Self::Removed { .. } => 255, + } + } + + /// Badge text shown in `--help` output (e.g. `[beta]`, `[deprecated]`). + pub fn badge(&self) -> Option<&'static str> { + match self { + Self::Stable => None, + Self::Rc => Some("[rc]"), + Self::Beta => Some("[beta]"), + Self::Alpha => Some("[alpha]"), + Self::EarlyAccess => Some("[early-access]"), + Self::Deprecated { .. } => Some("[deprecated]"), + Self::Removed { .. } => Some("[removed]"), + } + } + + /// Returns `true` if this command should be visible at the given + /// maturity level (lower rank = more mature). + pub fn visible_at(&self, maturity_rank: u8) -> bool { + match self { + // Deprecated commands are always visible (with badge). + Self::Deprecated { .. } => true, + // Removed commands are never visible. + Self::Removed { .. } => false, + // GA and pre-GA: visible if the user's threshold allows it. + _ => self.rank() <= maturity_rank, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rank_ordering() { + assert!(Stability::Stable.rank() < Stability::Rc.rank()); + assert!(Stability::Rc.rank() < Stability::Beta.rank()); + assert!(Stability::Beta.rank() < Stability::Alpha.rank()); + assert!(Stability::Alpha.rank() < Stability::EarlyAccess.rank()); + } + + #[test] + fn visible_at_threshold() { + // Stable is always visible at default (0) + assert!(Stability::Stable.visible_at(0)); + // Beta is NOT visible at default (0) + assert!(!Stability::Beta.visible_at(0)); + // Beta IS visible at rank 2+ + assert!(Stability::Beta.visible_at(2)); + assert!(Stability::Beta.visible_at(4)); + } + + #[test] + fn deprecated_always_visible() { + let dep = Stability::Deprecated { + message: "use v2".into(), + replacement: None, + removed_in: None, + }; + assert!(dep.visible_at(0)); + assert!(dep.visible_at(4)); + } + + #[test] + fn removed_never_visible() { + let rem = Stability::Removed { + message: "gone".into(), + }; + assert!(!rem.visible_at(0)); + assert!(!rem.visible_at(255)); + } + + #[test] + fn badge_text() { + assert_eq!(Stability::Stable.badge(), None); + assert_eq!(Stability::Beta.badge(), Some("[beta]")); + assert_eq!( + Stability::Deprecated { + message: String::new(), + replacement: None, + removed_in: None, + } + .badge(), + Some("[deprecated]") + ); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/text.rs b/seed/cli/query-parameters-openapi/github-npm/src/text.rs new file mode 100644 index 000000000000..b66cb4446ae2 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/text.rs @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// Max chars for CLI `--help` method descriptions (terminal-width friendly). +pub const CLI_DESCRIPTION_LIMIT: usize = 200; + +/// Convert a parameter name to an idiomatic kebab-case CLI flag. +/// +/// Handles snake_case (`min_start_time` → `min-start-time`), camelCase +/// (`pageToken` → `page-token`), and Header-Case names that already +/// contain dashes (`Idempotency-Key` → `idempotency-key`). Adjacent +/// separator characters never produce double dashes — both `_` and `-` +/// collapse to a single `-`, and an uppercase letter that immediately +/// follows a separator is *not* preceded by an additional dash. +pub fn to_kebab_flag(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for (i, ch) in s.chars().enumerate() { + if ch == '_' || ch == '-' { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } else if ch.is_uppercase() { + if i > 0 && !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + result +} + +/// Convert an identifier to SCREAMING_SNAKE_CASE, the canonical env-var +/// spelling for `--` flags. +/// +/// Mirrors [`to_kebab_flag`] then uppercases and swaps hyphens for +/// underscores: `pageToken` → `PAGE_TOKEN`, `min_start_time` → +/// `MIN_START_TIME`, `garden-id` → `GARDEN_ID`. Used by +/// `x-fern-sdk-variables` to derive the env-var fallback for each global. +pub fn to_screaming_snake(s: &str) -> String { + to_kebab_flag(s).to_ascii_uppercase().replace('-', "_") +} + +/// Truncates a description string to `max_chars` using smart boundaries. +/// +/// When `strip_links` is true, markdown links `[text](url)` are replaced with +/// just `text` to reclaim character budget (useful for CLI help / frontmatter). +/// When false, links are preserved (useful for skill body text where agents can +/// follow URLs). +/// +/// Truncation strategy: +/// 1. If a complete sentence (ending in `. `) fits within the limit, truncate there. +/// 2. Otherwise, break at the last word boundary (space) and append `…`. +/// 3. If no space exists, hard-cut at `max_chars - 1` and append `…`. +pub fn truncate_description(desc: &str, max_chars: usize, strip_links: bool) -> String { + if max_chars == 0 { + return String::new(); + } + + let cleaned = if strip_links { + strip_markdown_links(desc) + } else { + desc.to_string() + }; + let trimmed = cleaned.trim(); + + // Count chars (UTF-8 safe) + let char_count = trimmed.chars().count(); + if char_count <= max_chars { + return trimmed.to_string(); + } + + // Collect the first `max_chars` characters as a string to search within. + let prefix: String = trimmed.chars().take(max_chars).collect(); + + // Try to find the last complete sentence within the limit. + // A sentence ends with ". " followed by more text, or "." at the end of + // the prefix. We look for the last ". " to find a sentence boundary. + if let Some(sentence_end) = find_last_sentence_boundary(&prefix) { + let truncated: String = trimmed.chars().take(sentence_end).collect(); + return truncated; + } + + // Fall back to last word boundary (space) within the limit. + if let Some(last_space) = rfind_char_boundary(&prefix, ' ') { + let truncated: String = trimmed.chars().take(last_space).collect(); + return format!("{truncated}…"); + } + + // Hard cut — no spaces at all + let truncated: String = trimmed.chars().take(max_chars - 1).collect(); + format!("{truncated}…") +} + +/// Strips markdown-style links `[text](url)` and replaces them with just `text`. +fn strip_markdown_links(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let chars: Vec = s.chars().collect(); + let len = chars.len(); + let mut i = 0; + + while i < len { + if chars[i] == '[' { + // Look for the closing ] followed by ( + if let Some(close_bracket) = find_char_from(&chars, ']', i + 1) { + if close_bracket + 1 < len && chars[close_bracket + 1] == '(' { + if let Some(close_paren) = find_char_from(&chars, ')', close_bracket + 2) { + // Found a complete [text](url) — emit just the text + result.extend(&chars[i + 1..close_bracket]); + i = close_paren + 1; + continue; + } + } + } + } + result.push(chars[i]); + i += 1; + } + + result +} + +/// Finds the character-index of `target` starting from position `from`. +fn find_char_from(chars: &[char], target: char, from: usize) -> Option { + chars[from..] + .iter() + .position(|&c| c == target) + .map(|p| from + p) +} + +/// Finds the last sentence boundary within a char-indexed string. +/// A sentence boundary is a position right after ". " where we can cleanly cut. +/// Returns the char-count to include (up to and including the period). +fn find_last_sentence_boundary(prefix: &str) -> Option { + let chars: Vec = prefix.chars().collect(); + let mut last_boundary = None; + + for (i, _) in chars.iter().enumerate() { + if chars[i] == '.' { + let after_period = i + 1; + // Sentence boundary: period followed by a space, or period at end of prefix + if after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ') + { + last_boundary = Some(after_period); + } + } + } + + last_boundary +} + +/// Finds the last occurrence of `target` in a string, returning its char-index. +fn rfind_char_boundary(s: &str, target: char) -> Option { + let chars: Vec = s.chars().collect(); + chars.iter().rposition(|&c| c == target) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_desc_unchanged() { + let desc = "Lists all files."; + assert_eq!(truncate_description(desc, 200, true), "Lists all files."); + } + + #[test] + fn truncate_at_sentence_boundary() { + let desc = "Creates a file in Drive. This method supports multipart upload. See the guide for details on how to use it."; + // At limit 30, only the first sentence fits before the sentence boundary. + let result = truncate_description(desc, 30, true); + assert_eq!(result, "Creates a file in Drive."); + + // At limit 70, both first and second sentences fit. + let result = truncate_description(desc, 70, true); + assert_eq!( + result, + "Creates a file in Drive. This method supports multipart upload." + ); + } + + #[test] + fn truncate_at_word_boundary() { + let desc = "Create a guest user with access to a subset of Workspace capabilities"; + let result = truncate_description(desc, 50, true); + // Should cut at the last space before char 50 + assert!(result.ends_with('…')); + assert!(result.len() <= 55); // 50 chars + ellipsis + assert!(!result.contains("capabil")); // Should not cut mid-word + } + + #[test] + fn hard_cut_no_spaces() { + let desc = "abcdefghijklmnopqrstuvwxyz"; + let result = truncate_description(desc, 10, true); + assert_eq!(result, "abcdefghi…"); + } + + #[test] + fn strips_markdown_links() { + let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha."; + let result = truncate_description(desc, 200, true); + assert_eq!( + result, + "Create a guest user with access to a subset of Workspace capabilities. This feature is in Alpha." + ); + assert!(!result.contains("https://")); + assert!(!result.contains('[')); + } + + #[test] + fn preserves_links_when_strip_links_false() { + let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha."; + let result = truncate_description(desc, 500, false); + assert!(result.contains("https://support.google.com")); + assert!(result.contains("[subset of Workspace capabilities]")); + } + + #[test] + fn strips_markdown_links_and_truncates() { + let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is currently in Alpha. Please reach out to support if you are interested in enabling this feature."; + let result = truncate_description(desc, 120, true); + // After stripping the link, the sentence boundary should work. + assert!(result.contains("subset of Workspace capabilities.")); + assert!(!result.contains("https://")); + } + + #[test] + fn multibyte_safe() { + let desc = "Résumé création für Ñoño — a long description that should be safely truncated at word boundaries without panicking on multi-byte chars"; + let result = truncate_description(desc, 30, true); + assert!(result.ends_with('…') || result.chars().count() <= 30); + } + + #[test] + fn empty_and_whitespace() { + assert_eq!(truncate_description("", 100, true), ""); + assert_eq!(truncate_description(" ", 100, true), ""); + assert_eq!(truncate_description("", 0, true), ""); + } + + #[test] + fn test_strip_markdown_links() { + assert_eq!(strip_markdown_links("[text](http://example.com)"), "text"); + assert_eq!( + strip_markdown_links("Use [this link](http://a.com) and [that](http://b.com) too"), + "Use this link and that too" + ); + assert_eq!(strip_markdown_links("no links here"), "no links here"); + // Incomplete link syntax should be left alone + assert_eq!(strip_markdown_links("[broken"), "[broken"); + assert_eq!(strip_markdown_links("[text]no-parens"), "[text]no-parens"); + } + + #[test] + fn preserves_sentence_ending_at_limit() { + let desc = "Deletes a user."; + assert_eq!(truncate_description(desc, 15, true), "Deletes a user."); + } + + #[test] + fn does_not_cut_url_looking_periods() { + // Periods in URLs or abbreviations like "v1." shouldn't be treated as sentence ends + // unless followed by a space + let desc = "See the docs at developers.google.com for more details on this API endpoint"; + let result = truncate_description(desc, 50, true); + // Should truncate at word boundary, not at "developers." + assert!(result.ends_with('…')); + } + + #[test] + fn sentence_boundary_at_exact_limit() { + // Period falls exactly at the end of the prefix — should still detect it + let desc = "This is a complete sentence. And more text follows here."; + let result = truncate_description(desc, 28, true); + assert_eq!(result, "This is a complete sentence."); + } + + #[test] + fn zero_max_chars() { + assert_eq!(truncate_description("anything", 0, true), ""); + } + + #[test] + fn test_to_kebab_flag() { + // snake_case + assert_eq!(to_kebab_flag("page_token"), "page-token"); + assert_eq!(to_kebab_flag("user_id"), "user-id"); + assert_eq!(to_kebab_flag("min_start_time"), "min-start-time"); + assert_eq!(to_kebab_flag("a_b_c"), "a-b-c"); + // camelCase + assert_eq!(to_kebab_flag("pageToken"), "page-token"); + assert_eq!(to_kebab_flag("userId"), "user-id"); + assert_eq!(to_kebab_flag("minStartTime"), "min-start-time"); + assert_eq!(to_kebab_flag("eventTypeURI"), "event-type-u-r-i"); + // already kebab or simple + assert_eq!(to_kebab_flag("simple"), "simple"); + assert_eq!(to_kebab_flag("uuid"), "uuid"); + assert_eq!(to_kebab_flag(""), ""); + // Header-Case (HTTP header names — idempotency headers, custom + // headers — pass through to the flag builder as-is via the + // synthetic-parameter path). + assert_eq!(to_kebab_flag("Idempotency-Key"), "idempotency-key"); + assert_eq!(to_kebab_flag("X-Request-Id"), "x-request-id"); + assert_eq!(to_kebab_flag("Content-Type"), "content-type"); + // Defensive: doubled separators in mixed-case inputs collapse. + assert_eq!(to_kebab_flag("foo--bar"), "foo-bar"); + assert_eq!(to_kebab_flag("foo__bar"), "foo-bar"); + assert_eq!(to_kebab_flag("-leading-dash"), "leading-dash"); + } + + #[test] + fn test_to_screaming_snake() { + // camelCase → SCREAMING_SNAKE + assert_eq!(to_screaming_snake("gardenId"), "GARDEN_ID"); + assert_eq!(to_screaming_snake("pageToken"), "PAGE_TOKEN"); + // snake_case stays underscore-delimited and uppercases + assert_eq!(to_screaming_snake("min_start_time"), "MIN_START_TIME"); + // kebab inputs flatten the same way as camel + assert_eq!(to_screaming_snake("garden-id"), "GARDEN_ID"); + // single token + assert_eq!(to_screaming_snake("uuid"), "UUID"); + assert_eq!(to_screaming_snake(""), ""); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/validate.rs b/seed/cli/query-parameters-openapi/github-npm/src/validate.rs new file mode 100644 index 000000000000..8371b99969ca --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/validate.rs @@ -0,0 +1,839 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared input validation helpers. +//! +//! These functions harden CLI inputs against adversarial or accidentally +//! malformed values — especially important when the CLI is invoked by an +//! LLM agent rather than a human operator. + +use crate::error::CliError; +use std::path::{Path, PathBuf}; + +use crate::output::reject_dangerous_chars as reject_control_chars; + +/// Validates that `dir` is a safe output directory. +/// +/// The path is resolved relative to CWD. The function rejects paths that +/// would escape above CWD (e.g. `../../.ssh`) or contain null bytes / +/// control characters. +/// +/// Returns the canonicalized path on success. +pub fn validate_safe_output_dir(dir: &str) -> Result { + reject_control_chars(dir, "--output-dir")?; + + let path = Path::new(dir); + + // Reject absolute paths — force everything relative to CWD + if path.is_absolute() { + return Err(CliError::Validation(format!( + "--output-dir must be a relative path, got absolute path '{dir}'" + ))); + } + + // Canonicalize CWD and resolve the target under it + let cwd = std::env::current_dir() + .map_err(|e| CliError::Validation(format!("Failed to determine current directory: {e}")))?; + let resolved = cwd.join(path); + + // If the directory already exists, canonicalize. Otherwise, canonicalize + // the longest existing prefix and append the remaining segments. + let canonical = if resolved.exists() { + resolved.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to resolve --output-dir '{dir}': {e}")) + })? + } else { + normalize_non_existing(&resolved)? + }; + + let canonical_cwd = cwd.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to canonicalize current directory: {e}")) + })?; + + if !canonical.starts_with(&canonical_cwd) { + return Err(CliError::Validation(format!( + "--output-dir '{dir}' resolves to '{}' which is outside the current directory", + canonical.display() + ))); + } + + Ok(canonical) +} + +/// Validates that `dir` is a safe directory for reading files (e.g. `--dir` +/// in `script +push`). +/// +/// Similar to [`validate_safe_output_dir`] but also follows symlinks +/// safely and ensures the resolved path stays under CWD. +pub fn validate_safe_dir_path(dir: &str) -> Result { + reject_control_chars(dir, "--dir")?; + + let path = Path::new(dir); + + // "." is always safe (CWD itself) + if dir == "." { + return std::env::current_dir().map_err(|e| { + CliError::Validation(format!("Failed to determine current directory: {e}")) + }); + } + + if path.is_absolute() { + return Err(CliError::Validation(format!( + "--dir must be a relative path, got absolute path '{dir}'" + ))); + } + + let cwd = std::env::current_dir() + .map_err(|e| CliError::Validation(format!("Failed to determine current directory: {e}")))?; + let resolved = cwd.join(path); + + let canonical = resolved + .canonicalize() + .map_err(|e| CliError::Validation(format!("Failed to resolve --dir '{dir}': {e}")))?; + + let canonical_cwd = cwd.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to canonicalize current directory: {e}")) + })?; + + if !canonical.starts_with(&canonical_cwd) { + return Err(CliError::Validation(format!( + "--dir '{dir}' resolves to '{}' which is outside the current directory", + canonical.display() + ))); + } + + Ok(canonical) +} + +/// Validates that a file path (e.g. `--upload` or `--output`) is safe. +/// +/// Rejects paths that escape above CWD via `..` traversal, contain +/// control characters, or follow symlinks to locations outside CWD. +/// Absolute paths are allowed (reading an existing file from a known +/// location is legitimate) but the resolved target must still live +/// under CWD. +/// +/// # TOCTOU caveat +/// +/// This is a best-effort defence-in-depth check. A local attacker with +/// write access to a parent directory could replace a path component +/// between this validation and the subsequent I/O. Fully eliminating +/// TOCTOU would require `openat(O_NOFOLLOW)` on each path component, +/// which is tracked as a follow-up for Unix platforms. +pub fn validate_safe_file_path(path_str: &str, flag_name: &str) -> Result { + reject_control_chars(path_str, flag_name)?; + + let path = Path::new(path_str); + let cwd = std::env::current_dir() + .map_err(|e| CliError::Validation(format!("Failed to determine current directory: {e}")))?; + + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + + // For existing files, canonicalize to resolve symlinks. + // For non-existing files, get the prefix canonicalized then normalize + // the remaining components to resolve any `..` or `.` segments. + let canonical = if resolved.exists() { + resolved.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to resolve {flag_name} '{path_str}': {e}")) + })? + } else { + let raw = normalize_non_existing(&resolved)?; + // normalize_non_existing does NOT resolve `..` in the non-existent + // suffix. We must resolve them here to prevent bypass via paths like + // `non_existent/../../etc/passwd`. + normalize_dotdot(&raw) + }; + + let canonical_cwd = cwd.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to canonicalize current directory: {e}")) + })?; + + if !canonical.starts_with(&canonical_cwd) { + return Err(CliError::Validation(format!( + "{flag_name} '{}' resolves to '{}' which is outside the current directory", + path_str, + canonical.display() + ))); + } + + Ok(canonical) +} + +/// Resolve `.` and `..` components in a path without touching the filesystem. +fn normalize_dotdot(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + c => out.push(c), + } + } + out +} + +// reject_control_chars is now a re-export from crate::output (see top of file) + +/// Resolves a path that may not exist yet by canonicalizing the existing +/// prefix and appending remaining components. +fn normalize_non_existing(path: &Path) -> Result { + let mut resolved = PathBuf::new(); + let mut remaining = Vec::new(); + + // Walk backwards until we find a component that exists + let mut current = path.to_path_buf(); + loop { + if current.exists() { + resolved = current + .canonicalize() + .map_err(|e| CliError::Validation(format!("Failed to canonicalize path: {e}")))?; + break; + } + if let Some(name) = current.file_name() { + remaining.push(name.to_os_string()); + } else { + // We've exhausted the path without finding an existing prefix + return Err(CliError::Validation(format!( + "Cannot resolve path '{}'", + path.display() + ))); + } + current = match current.parent() { + Some(p) => p.to_path_buf(), + None => break, + }; + } + + // Append remaining segments (in reverse since we collected them backwards) + for seg in remaining.into_iter().rev() { + resolved.push(seg); + } + + Ok(resolved) +} + +/// Characters to encode in a single URL path segment. Keeps RFC 3986 §2.3 +/// unreserved characters that commonly appear in resource IDs (`-` and `_`) +/// unencoded; encodes everything else including `.` (dots appear in email-style +/// calendar IDs and should not carry path semantics). +use percent_encoding::{AsciiSet, CONTROLS}; +const PATH_SEGMENT: &AsciiSet = &CONTROLS + .add(b' ').add(b'!').add(b'"').add(b'#').add(b'$').add(b'%') + .add(b'&').add(b'\'').add(b'(').add(b')').add(b'*').add(b'+') + .add(b',').add(b'.').add(b'/').add(b':').add(b';').add(b'<') + .add(b'=').add(b'>').add(b'?').add(b'@').add(b'[').add(b'\\') + .add(b']').add(b'^').add(b'`').add(b'{').add(b'|').add(b'}') + .add(b'~'); + +/// Percent-encode a value for use as a single URL path segment (e.g., file ID, +/// calendar ID, message ID). Hyphens and underscores are left unencoded since +/// they are unreserved per RFC 3986 and ubiquitous in resource IDs. +pub fn encode_path_segment(s: &str) -> String { + use percent_encoding::utf8_percent_encode; + utf8_percent_encode(s, PATH_SEGMENT).to_string() +} + +/// Percent-encode a value for use in URI path templates where `/` should stay +/// as a path separator (e.g., RFC 6570 `{+name}` expansions). +/// +/// Each path segment is encoded independently, then joined with `/`, so +/// dangerous characters like `#`/`?` are still escaped while hierarchical +/// resource names such as `projects/p/locations/l` remain readable. +pub fn encode_path_preserving_slashes(s: &str) -> String { + s.split('/') + .map(encode_path_segment) + .collect::>() + .join("/") +} + +/// Validate a multi-segment resource name (e.g., `spaces/ABC`, `subscriptions/123`). +/// Rejects path traversal, control characters, and URL-special characters including `%` +/// to prevent URL-encoded bypasses. Returns the validated name or an error. +pub fn validate_resource_name(s: &str) -> Result<&str, CliError> { + if s.is_empty() { + return Err(CliError::Validation( + "Resource name must not be empty".to_string(), + )); + } + if s.split('/').any(|seg| seg == "..") { + return Err(CliError::Validation(format!( + "Resource name must not contain path traversal ('..') segments: {s}" + ))); + } + if s.chars() + .any(|c| c == '\0' || c.is_control() || crate::output::is_dangerous_unicode(c)) + { + return Err(CliError::Validation(format!( + "Resource name contains invalid characters: {s}" + ))); + } + // Reject URL-special characters that could inject query params or fragments + if s.contains('?') || s.contains('#') { + return Err(CliError::Validation(format!( + "Resource name must not contain '?' or '#': {s}" + ))); + } + // Reject '%' to prevent URL-encoded bypasses (e.g. %2e%2e for ..) + if s.contains('%') { + return Err(CliError::Validation(format!( + "Resource name must not contain '%' (URL encoding bypass attempt): {s}" + ))); + } + Ok(s) +} + +/// Validate an API identifier (service name, version string) for use in +/// cache filenames and discovery URLs. Only alphanumeric characters, hyphens, +/// underscores, and dots are allowed to prevent path traversal and injection. +pub fn validate_api_identifier(s: &str) -> Result<&str, CliError> { + if s.is_empty() { + return Err(CliError::Validation( + "API identifier must not be empty".to_string(), + )); + } + if !s + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + { + return Err(CliError::Validation(format!( + "API identifier contains invalid characters (only alphanumeric, '-', '_', '.' allowed): {s}" + ))); + } + Ok(s) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use tempfile::tempdir; + + // --- validate_safe_output_dir --- + + #[test] + #[serial] + fn test_output_dir_relative_subdir() { + // Create a real temp dir and change into it for the test + let dir = tempdir().unwrap(); + // Canonicalize to handle macOS /var -> /private/var symlink + let canonical_dir = dir.path().canonicalize().unwrap(); + let sub = canonical_dir.join("output"); + fs::create_dir_all(&sub).unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_output_dir("output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + #[test] + #[serial] + fn test_output_dir_rejects_symlink_traversal() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + // Create a directory inside the tempdir + let allowed_dir = canonical_dir.join("allowed"); + fs::create_dir(&allowed_dir).unwrap(); + + // Create a symlink pointing OUTSIDE the tempdir (e.g. to /tmp) + let symlink_path = canonical_dir.join("sneaky_link"); + #[cfg(unix)] + std::os::unix::fs::symlink("/tmp", &symlink_path).unwrap(); + #[cfg(windows)] + return; // Skip on Windows due to privilege requirements for symlinks + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + // Try to validate the symlink resolving outside CWD + let result = validate_safe_output_dir("sneaky_link"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("outside the current directory"), "got: {msg}"); + } + + #[test] + #[serial] + fn test_output_dir_rejects_traversal() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_output_dir("../../.ssh"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("outside the current directory"), "got: {msg}"); + } + + #[test] + fn test_output_dir_rejects_absolute() { + assert!(validate_safe_output_dir("/tmp/evil").is_err()); + } + + #[test] + fn test_output_dir_rejects_null_bytes() { + assert!(validate_safe_output_dir("foo\0bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_control_chars() { + assert!(validate_safe_output_dir("foo\x01bar").is_err()); + } + + #[test] + #[serial] + fn test_output_dir_non_existing_subdir() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_output_dir("new/nested/dir"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!( + result.is_ok(), + "expected Ok for non-existing subdir, got: {result:?}" + ); + } + + // --- validate_safe_dir_path --- + + #[test] + fn test_dir_path_cwd() { + assert!(validate_safe_dir_path(".").is_ok()); + } + + #[test] + #[serial] + fn test_dir_path_rejects_traversal() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_dir_path("../../etc"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err()); + } + + #[test] + fn test_dir_path_rejects_absolute() { + assert!(validate_safe_dir_path("/usr/local").is_err()); + } + + // --- reject_control_chars --- + + #[test] + fn test_reject_control_chars_clean() { + assert!(reject_control_chars("hello/world", "test").is_ok()); + } + + #[test] + fn test_reject_control_chars_tab() { + assert!(reject_control_chars("hello\tworld", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_newline() { + assert!(reject_control_chars("hello\nworld", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_del() { + assert!(reject_control_chars("hello\x7Fworld", "test").is_err()); + } + + // -- encode_path_segment -------------------------------------------------- + + #[test] + fn test_encode_path_segment_plain_id() { + assert_eq!(encode_path_segment("abc123"), "abc123"); + } + + #[test] + fn test_encode_path_segment_hyphenated_id() { + // Hyphens and underscores are unreserved (RFC 3986 §2.3) and common in + // resource IDs (UUIDs, slugs). They must not be percent-encoded. + assert_eq!(encode_path_segment("file-123"), "file-123"); + assert_eq!(encode_path_segment("my_resource_id"), "my_resource_id"); + assert_eq!( + encode_path_segment("550e8400-e29b-41d4-a716-446655440000"), + "550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn test_encode_path_segment_email() { + // Calendar IDs are often email addresses + let encoded = encode_path_segment("user@gmail.com"); + assert!(!encoded.contains('@')); + assert!(!encoded.contains('.')); + } + + #[test] + fn test_encode_path_segment_query_injection() { + // LLM might include query params in an ID by mistake + let encoded = encode_path_segment("fileid?fields=name"); + assert!(!encoded.contains('?')); + assert!(!encoded.contains('=')); + } + + #[test] + fn test_encode_path_segment_fragment_injection() { + let encoded = encode_path_segment("fileid#section"); + assert!(!encoded.contains('#')); + } + + #[test] + fn test_encode_path_segment_path_traversal() { + // Encoding makes traversal segments harmless + let encoded = encode_path_segment("../../etc/passwd"); + assert!(!encoded.contains('/')); + assert!(!encoded.contains("..")); + } + + #[test] + fn test_encode_path_segment_unicode() { + // LLM might pass unicode characters + let encoded = encode_path_segment("日本語ID"); + assert!(!encoded.contains('日')); + } + + #[test] + fn test_encode_path_segment_spaces() { + let encoded = encode_path_segment("my file id"); + assert!(!encoded.contains(' ')); + } + + #[test] + fn test_encode_path_segment_already_encoded() { + // LLM might double-encode by passing pre-encoded values + let encoded = encode_path_segment("user%40gmail.com"); + // The % itself gets encoded to %25, so %40 becomes %2540 + // This prevents double-encoding issues at the HTTP layer + assert!(encoded.contains("%2540")); + } + + #[test] + fn test_encode_path_preserving_slashes_hierarchical_name() { + let encoded = encode_path_preserving_slashes("projects/p1/locations/us/topics/t1"); + assert_eq!(encoded, "projects/p1/locations/us/topics/t1"); + } + + #[test] + fn test_encode_path_preserving_slashes_escapes_reserved_chars() { + let encoded = encode_path_preserving_slashes("hash#1/child?x=y"); + assert_eq!(encoded, "hash%231/child%3Fx%3Dy"); + } + + #[test] + fn test_encode_path_preserving_slashes_spaces_and_unicode() { + let encoded = encode_path_preserving_slashes("タイムライン 1/列 A"); + assert!(!encoded.contains(' ')); + assert!(encoded.contains('/')); + } + + // -- validate_resource_name ----------------------------------------------- + + #[test] + fn test_validate_resource_name_valid() { + assert!(validate_resource_name("spaces/ABC123").is_ok()); + assert!(validate_resource_name("subscriptions/my-sub").is_ok()); + assert!(validate_resource_name("@default").is_ok()); + assert!(validate_resource_name("projects/p1/topics/t1").is_ok()); + } + + #[test] + fn test_validate_resource_name_traversal() { + assert!(validate_resource_name("../../etc/passwd").is_err()); + assert!(validate_resource_name("spaces/../other").is_err()); + assert!(validate_resource_name("..").is_err()); + } + + #[test] + fn test_validate_resource_name_control_chars() { + assert!(validate_resource_name("spaces/\0bad").is_err()); + assert!(validate_resource_name("spaces/\nbad").is_err()); + assert!(validate_resource_name("spaces/\rbad").is_err()); + assert!(validate_resource_name("spaces/\tbad").is_err()); + } + + #[test] + fn test_validate_resource_name_empty() { + assert!(validate_resource_name("").is_err()); + } + + #[test] + fn test_validate_resource_name_query_injection() { + // LLMs might append query strings or fragments to resource names + assert!(validate_resource_name("spaces/ABC?key=val").is_err()); + assert!(validate_resource_name("spaces/ABC#fragment").is_err()); + } + + #[test] + fn test_validate_resource_name_error_messages_are_clear() { + let err = validate_resource_name("").unwrap_err(); + assert!(err.to_string().contains("must not be empty")); + + let err = validate_resource_name("../bad").unwrap_err(); + assert!(err.to_string().contains("path traversal")); + + let err = validate_resource_name("bad\0id").unwrap_err(); + assert!(err.to_string().contains("invalid characters")); + } + + #[test] + fn test_validate_resource_name_percent_bypass() { + // %2e%2e is .. + assert!(validate_resource_name("%2e%2e").is_err()); + assert!(validate_resource_name("spaces/%2e%2e/etc").is_err()); + // Just % should be rejected too + assert!(validate_resource_name("spaces/100%").is_err()); + } + + // --- reject_control_chars Unicode --- + + #[test] + fn test_reject_control_chars_zero_width_space() { + // U+200B zero-width space + assert!(reject_control_chars("foo\u{200B}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_bom() { + // U+FEFF byte-order mark / zero-width no-break space + assert!(reject_control_chars("foo\u{FEFF}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_rtl_override() { + // U+202E RIGHT-TO-LEFT OVERRIDE + assert!(reject_control_chars("foo\u{202E}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_unicode_line_separator() { + // U+2028 LINE SEPARATOR + assert!(reject_control_chars("foo\u{2028}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_paragraph_separator() { + // U+2029 PARAGRAPH SEPARATOR + assert!(reject_control_chars("foo\u{2029}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_zero_width_joiner() { + // U+200D ZERO WIDTH JOINER + assert!(reject_control_chars("foo\u{200D}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_normal_unicode_ok() { + // CJK, accented characters and emoji should pass + assert!(reject_control_chars("日本語", "test").is_ok()); + assert!(reject_control_chars("café", "test").is_ok()); + assert!(reject_control_chars("αβγ", "test").is_ok()); + } + + // --- path validator Unicode (via validate_safe_output_dir) --- + + #[test] + fn test_output_dir_rejects_zero_width_chars() { + // U+200B in a path segment + assert!(validate_safe_output_dir("foo\u{200B}bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_rtl_override() { + assert!(validate_safe_output_dir("foo\u{202E}bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_unicode_line_separator() { + assert!(validate_safe_output_dir("foo\u{2028}bar").is_err()); + } + + // --- validate_resource_name Unicode --- + + #[test] + fn test_validate_resource_name_zero_width_chars() { + // U+200B, U+200D, U+FEFF all rejected + assert!(validate_resource_name("foo\u{200B}bar").is_err()); + assert!(validate_resource_name("foo\u{200D}bar").is_err()); + assert!(validate_resource_name("foo\u{FEFF}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_unicode_line_seps() { + assert!(validate_resource_name("foo\u{2028}bar").is_err()); + assert!(validate_resource_name("foo\u{2029}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_rtl_override() { + assert!(validate_resource_name("foo\u{202E}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_bidi_embedding() { + // U+202A LEFT-TO-RIGHT EMBEDDING, U+202B RIGHT-TO-LEFT EMBEDDING + assert!(validate_resource_name("foo\u{202A}bar").is_err()); + assert!(validate_resource_name("foo\u{202B}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_homoglyphs_pass_through() { + // Cyrillic lookalikes are intentionally allowed (homoglyph detection + // is out of scope for this validator — see validate_resource_name docs). + assert!(validate_resource_name("spaces/ΑΒС").is_ok()); // Cyrillic С + } + + #[test] + fn test_validate_resource_name_overlong_accepted() { + // No length limit — documents current behaviour. + let long = "a".repeat(10_000); + assert!(validate_resource_name(&long).is_ok()); + } + + // --- validate_api_identifier --- + + #[test] + fn test_validate_api_identifier_valid() { + assert_eq!(validate_api_identifier("drive").unwrap(), "drive"); + assert_eq!(validate_api_identifier("v3").unwrap(), "v3"); + assert_eq!( + validate_api_identifier("directory_v1").unwrap(), + "directory_v1" + ); + assert_eq!( + validate_api_identifier("admin.reports_v1").unwrap(), + "admin.reports_v1" + ); + assert_eq!(validate_api_identifier("v2beta1").unwrap(), "v2beta1"); + } + + #[test] + fn test_validate_api_identifier_rejects_path_traversal() { + assert!(validate_api_identifier("../etc/passwd").is_err()); + assert!(validate_api_identifier("foo/../bar").is_err()); + } + + #[test] + fn test_validate_api_identifier_rejects_special_chars() { + assert!(validate_api_identifier("drive?key=val").is_err()); + assert!(validate_api_identifier("drive#frag").is_err()); + assert!(validate_api_identifier("drive%2f..").is_err()); + assert!(validate_api_identifier("v3 ").is_err()); + assert!(validate_api_identifier("v3\n").is_err()); + } + + #[test] + fn test_validate_api_identifier_empty() { + assert!(validate_api_identifier("").is_err()); + } + + // --- validate_safe_file_path --- + + #[test] + #[serial] + fn test_file_path_relative_is_ok() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + fs::write(canonical_dir.join("test.txt"), "data").unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("test.txt", "--upload"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + #[test] + #[serial] + fn test_file_path_rejects_traversal() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("../../etc/passwd", "--upload"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err(), "path traversal should be rejected"); + assert!( + result.unwrap_err().to_string().contains("outside"), + "error should mention 'outside'" + ); + } + + #[test] + fn test_file_path_rejects_control_chars() { + let result = validate_safe_file_path("file\x00.txt", "--output"); + assert!(result.is_err(), "null bytes should be rejected"); + } + + #[test] + #[serial] + fn test_file_path_rejects_symlink_escape() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + // Create a symlink that points outside the directory + #[cfg(unix)] + { + let link_path = canonical_dir.join("escape"); + std::os::unix::fs::symlink("/tmp", &link_path).unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("escape/secret.txt", "--output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err(), "symlink escape should be rejected"); + } + } + + #[test] + #[serial] + fn test_file_path_rejects_traversal_via_nonexistent_prefix() { + // Regression: non_existent/../../etc/passwd could bypass starts_with + // because normalize_non_existing preserves ".." in the non-existent + // suffix. The normalize_dotdot fix resolves this. + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("doesnt_exist/../../etc/passwd", "--output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!( + result.is_err(), + "traversal via non-existent prefix should be rejected" + ); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/websocket/auth.rs b/seed/cli/query-parameters-openapi/github-npm/src/websocket/auth.rs new file mode 100644 index 000000000000..4b74abfd54d2 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/websocket/auth.rs @@ -0,0 +1,532 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket authentication: query-param, header, and first-message +//! variants. Each variant takes an [`AuthCredentialSource`] directly — the +//! WS path deliberately bypasses [`AuthProvider`](crate::auth::AuthProvider) +//! (which is shaped around `reqwest::RequestBuilder`); see +//! `docs/adr/0001-auth-provider-no-cred-extraction.md`. + +use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; +use secrecy::ExposeSecret; +use serde_json::Value; + +use crate::auth::AuthCredentialSource; +use crate::error::CliError; + +/// Percent-encoding set for query-string components: encode everything that +/// is not in the application/x-www-form-urlencoded "safe" set, plus the +/// reserved characters that would otherwise terminate the value (`&`, `=`, +/// `#`, `+`, ` `, `/`, `?`). Mirrors the `url::form_urlencoded` set without +/// adding `url` as a direct dep. +const QUERY_VALUE: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'&') + .add(b'+') + .add(b'/') + .add(b'<') + .add(b'=') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + +/// Where the WS handshake / first frame puts the credential. +/// +/// Variants take an [`AuthCredentialSource`] directly rather than a +/// resolved string so the same `cli > env > file` precedence patterns +/// users already configure for the HTTP path work without extra plumbing. +/// +/// # `AuthCredentialSource::Cli` footgun +/// +/// `AuthCredentialSource::cli("token")` is bound to a clap argument and +/// resolves to `None` until [`AuthCredentialSource::finalize`] is called +/// against the parsed matches. The HTTP path runs `finalize` automatically +/// inside `CliApp::run`; the WS path is invoked from a custom-command +/// handler that does *not* go through that finalize step. If you want +/// CLI-bound creds, either: +/// +/// - prefer `AuthCredentialSource::from_env(...)` so the same scheme used +/// by `auth_scheme_env` Just Works; +/// - or call `source = source.finalize(matches)` yourself before passing +/// the source into `WsAuth::*`. +/// +/// Missing creds surface as `CliError::Auth` so the failure mode is loud, +/// not silent. +pub enum WsAuth { + /// Append the credential as a query parameter on the connect URL. + /// Example: `wss://api.example.com/stream?authorization=`. + QueryParam(String, AuthCredentialSource), + /// Send the credential as an HTTP header on the WS upgrade request. + /// Example: a standard `X-Api-Key: ` header. + /// + /// # Header-value prefixes (footgun) + /// + /// The source's resolved value becomes the *entire* header value. + /// Some APIs require `Authorization: Token ` — the literal word + /// `Token` is part of the value, NOT a scheme the library prepends. + /// **Prefer the convenience constructors [`WsAuth::bearer`] / + /// [`WsAuth::token`]** rather than baking the prefix into a literal + /// or closure by hand; they're auditable in one place and impossible + /// to misspell. + Header(String, AuthCredentialSource), + /// Send multiple HTTP headers on the WS upgrade request. Use when the + /// API requires more than one header on the handshake (e.g. an auth + /// header plus an API-version header). Each pair is validated + /// against the WS-protocol reserved-header deny-list and each source + /// must resolve to a non-empty value. + Headers(Vec<(String, AuthCredentialSource)>), + /// Merge the credential into the *first* outbound JSON frame as the + /// named field. Useful for APIs that authenticate via a "configure + /// session" message on the first text frame. + FirstMessage(String, AuthCredentialSource), + /// No auth (anonymous connection, or auth handled by the caller + /// outside this module). + None, +} + +impl WsAuth { + /// `Authorization: Bearer ` convenience. Prepends the literal + /// `Bearer ` to the resolved credential so callers cannot + /// accidentally double-prefix or omit it. Use for any RFC-6750 + /// bearer-token API. + pub fn bearer(source: AuthCredentialSource) -> Self { + WsAuth::Header("Authorization".into(), prefix_source(source, "Bearer ")) + } + + /// `Authorization: Token ` convenience. Prepends the literal + /// `Token ` to the resolved credential. Use for APIs that treat the + /// word `Token` as part of the value (not a scheme tungstenite + /// prepends) — callers that miss this footgun get a confusing 401 + /// from the upgrade. + pub fn token(source: AuthCredentialSource) -> Self { + WsAuth::Header("Authorization".into(), prefix_source(source, "Token ")) + } + + /// Apply auth to the URL and header list before the handshake. + /// + /// For [`WsAuth::QueryParam`] this appends `?key=value` (or `&key=value`) + /// to `url`. For [`WsAuth::Header`] it pushes `(name, value)` onto + /// `headers`. For [`WsAuth::FirstMessage`] and [`WsAuth::None`] it's + /// a no-op — `FirstMessage` is applied by [`Self::merge_into_first_message`] + /// before the first send. + /// + /// Returns an error if the credential is required (i.e. variant is + /// not `None`) but the source resolves to `None` — that's almost + /// certainly a misconfiguration the user should see immediately. + pub fn apply_to_url_and_headers( + &self, + url: &mut String, + headers: &mut Vec<(String, String)>, + ) -> Result<(), CliError> { + match self { + WsAuth::QueryParam(key, source) => { + let secret = source.resolve().ok_or_else(|| { + CliError::Auth(format!( + "WebSocket auth: credential for query param `{key}` is unset" + )) + })?; + append_query_param(url, key, secret.expose_secret()); + Ok(()) + } + WsAuth::Header(name, source) => { + apply_single_header(name, source, headers) + } + WsAuth::Headers(pairs) => { + for (name, source) in pairs { + apply_single_header(name, source, headers)?; + } + Ok(()) + } + WsAuth::FirstMessage(_, _) | WsAuth::None => Ok(()), + } + } + + /// Merge the credential into the first outbound JSON frame. + /// + /// Used only for [`WsAuth::FirstMessage`]; other variants are a no-op. + /// The frame must be a JSON object — merging a top-level field into a + /// non-object value is a misconfiguration and surfaces as `Validation`. + pub fn merge_into_first_message(&self, msg: &mut Value) -> Result<(), CliError> { + if let WsAuth::FirstMessage(field, source) = self { + let secret = source.resolve().ok_or_else(|| { + CliError::Auth(format!( + "WebSocket auth: credential for first-message field `{field}` is unset" + )) + })?; + // Pre-compute the type name string so the error closure + // doesn't borrow `msg` while `as_object_mut` already holds a + // mutable borrow. + let observed = type_name(msg); + let obj = msg.as_object_mut().ok_or_else(|| { + CliError::Validation(format!( + "WebSocket auth: first message must be a JSON object to inject `{field}` \ + (got {observed})" + )) + })?; + obj.insert(field.clone(), Value::String(secret.expose_secret().to_string())); + } + Ok(()) + } +} + +/// Shared body for `Header` / `Headers` application — validates the name +/// against the reserved-handshake-header deny-list and resolves the source. +fn apply_single_header( + name: &str, + source: &AuthCredentialSource, + headers: &mut Vec<(String, String)>, +) -> Result<(), CliError> { + // Reject WS-protocol headers — letting a customer set `Host`, + // `Upgrade`, `Connection`, or `Sec-WebSocket-*` would silently + // clobber the auto-generated values from `IntoClientRequest` and + // produce a confusing handshake failure. Fail loudly. + if is_reserved_handshake_header(name) { + return Err(CliError::Validation(format!( + "WebSocket auth: header `{name}` is a WS-protocol \ + header and cannot be set via WsAuth — the handshake \ + machinery sets it automatically" + ))); + } + let secret = source.resolve().ok_or_else(|| { + CliError::Auth(format!( + "WebSocket auth: credential for header `{name}` is unset" + )) + })?; + headers.push((name.to_string(), secret.expose_secret().to_string())); + Ok(()) +} + +/// Append `?key=value` (or `&key=value` if a `?` is already present) to a +/// URL string. Percent-encodes the value so credentials with `&`, `=`, or +/// other URL-special characters survive the round-trip intact. +fn append_query_param(url: &mut String, key: &str, value: &str) { + let separator = if url.contains('?') { '&' } else { '?' }; + url.push(separator); + url.push_str(&utf8_percent_encode(key, QUERY_VALUE).to_string()); + url.push('='); + url.push_str(&utf8_percent_encode(value, QUERY_VALUE).to_string()); +} + +/// Wrap `source` so its resolved value gets `prefix` prepended. +/// `AuthCredentialSource` derives `Clone`, so the move-closure can keep +/// re-resolving each request without consuming the original source. +fn prefix_source(source: AuthCredentialSource, prefix: &'static str) -> AuthCredentialSource { + AuthCredentialSource::closure(move || { + source + .resolve() + .map(|s| format!("{prefix}{}", s.expose_secret())) + }) +} + +/// Names of HTTP headers the WS handshake machinery sets itself. Setting +/// any of them via `WsAuth::Header` would either clobber the correct value +/// or get clobbered by tungstenite — both end in a confusing handshake +/// failure. Reject up front. +fn is_reserved_handshake_header(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + matches!( + lower.as_str(), + "host" + | "upgrade" + | "connection" + | "sec-websocket-key" + | "sec-websocket-version" + | "sec-websocket-extensions" + | "sec-websocket-protocol" + | "sec-websocket-accept" + ) +} + +fn type_name(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn literal(v: &str) -> AuthCredentialSource { + AuthCredentialSource::literal(v) + } + + #[test] + fn query_param_appends_to_clean_url() { + let mut url = "wss://api.example.com/v1/socket".to_string(); + let mut headers = Vec::new(); + WsAuth::QueryParam("authorization".into(), literal("bearer-token")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert!(url.starts_with("wss://api.example.com/v1/socket?")); + assert!(url.contains("authorization=bearer-token")); + assert!(headers.is_empty()); + } + + #[test] + fn query_param_appends_with_ampersand_when_query_present() { + let mut url = "wss://api.example.com/v1/socket?agent_id=abc".to_string(); + let mut headers = Vec::new(); + WsAuth::QueryParam("authorization".into(), literal("tok")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/v1/socket?agent_id=abc&authorization=tok"); + } + + #[test] + fn query_param_percent_encodes_special_chars() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::QueryParam("token".into(), literal("a&b=c d")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + // Percent-encoded: & → %26, = → %3D, space → %20 (we encode all + // reserved characters consistently rather than using application/ + // x-www-form-urlencoded's `+` for space — wss:// query strings + // tend to round-trip the percent form more reliably across libs.) + assert!(url.contains("token=a%26b%3Dc%20d"), "url: {url}"); + } + + #[test] + fn header_adds_to_header_list_does_not_touch_url() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::Header("xi-api-key".into(), literal("sk-test")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/"); + assert_eq!(headers, vec![("xi-api-key".to_string(), "sk-test".to_string())]); + } + + #[test] + fn first_message_is_noop_at_handshake() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::FirstMessage("xi_api_key".into(), literal("sk-fm")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/"); + assert!(headers.is_empty()); + } + + #[test] + fn none_is_noop() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::None + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/"); + assert!(headers.is_empty()); + } + + #[test] + fn missing_credential_for_header_surfaces_as_auth_error() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + // Empty literal resolves to None — same path as a missing env var. + let err = WsAuth::Header("xi-api-key".into(), literal("")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + assert!(err.to_string().contains("xi-api-key")); + } + + #[test] + fn missing_credential_for_query_param_surfaces_as_auth_error() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + let err = WsAuth::QueryParam("authorization".into(), literal("")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + assert!(err.to_string().contains("authorization")); + } + + #[test] + fn first_message_merges_field_into_json_object() { + let mut msg = serde_json::json!({"text": "hello", "voice_settings": {"stability": 0.5}}); + WsAuth::FirstMessage("xi_api_key".into(), literal("sk-merged")) + .merge_into_first_message(&mut msg) + .unwrap(); + assert_eq!(msg["xi_api_key"], "sk-merged"); + assert_eq!(msg["text"], "hello"); + } + + #[test] + fn first_message_rejects_non_object() { + let mut msg = serde_json::json!(["not", "an", "object"]); + let err = WsAuth::FirstMessage("xi_api_key".into(), literal("sk")) + .merge_into_first_message(&mut msg) + .expect_err("array first frame should error"); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn first_message_missing_credential_errors() { + let mut msg = serde_json::json!({}); + let err = WsAuth::FirstMessage("xi_api_key".into(), literal("")) + .merge_into_first_message(&mut msg) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + } + + #[test] + fn header_rejects_ws_protocol_reserved_names() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + for reserved in &[ + "Host", + "host", + "Upgrade", + "Connection", + "Sec-WebSocket-Key", + "Sec-WebSocket-Version", + "Sec-WebSocket-Protocol", + ] { + let err = WsAuth::Header((*reserved).into(), literal("x")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err(reserved); + assert!(matches!(err, CliError::Validation(_)), + "reserved `{reserved}` should validation-error, got: {err:?}"); + } + // Sanity: a non-reserved name passes. + assert!(WsAuth::Header("X-My-Custom".into(), literal("x")) + .apply_to_url_and_headers(&mut url, &mut headers) + .is_ok()); + } + + #[test] + fn headers_variant_emits_all_pairs_in_order() { + let mut url = "wss://api.example.com/v1/realtime?model=test".to_string(); + let mut headers = Vec::new(); + WsAuth::Headers(vec![ + ( + "Authorization".into(), + literal("Bearer sk-test"), + ), + ( + "X-Api-Version".into(), + literal("v1"), + ), + ]) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers, + vec![ + ("Authorization".to_string(), "Bearer sk-test".to_string()), + ("X-Api-Version".to_string(), "v1".to_string()), + ] + ); + // URL is unchanged. + assert_eq!(url, "wss://api.example.com/v1/realtime?model=test"); + } + + #[test] + fn headers_variant_rejects_reserved_names_per_pair() { + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + let err = WsAuth::Headers(vec![ + ("X-Custom".into(), literal("ok")), + ("Upgrade".into(), literal("nope")), + ]) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("reserved header should error"); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn headers_variant_missing_credential_errors() { + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + let err = WsAuth::Headers(vec![ + ("Authorization".into(), literal("Bearer xyz")), + ("X-Api-Version".into(), literal("")), + ]) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + // Auth-error message names the missing header. + assert!(err.to_string().contains("X-Api-Version")); + } + + #[test] + fn bearer_helper_prepends_literal_bearer_space() { + let mut url = "wss://api.example.com/v1/realtime".to_string(); + let mut headers = Vec::new(); + WsAuth::bearer(literal("sk-test")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers, + vec![("Authorization".to_string(), "Bearer sk-test".to_string())] + ); + } + + #[test] + fn token_helper_prepends_literal_token_space() { + let mut url = "wss://api.example.com/v1/listen".to_string(); + let mut headers = Vec::new(); + WsAuth::token(literal("dg_secret")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers, + vec![("Authorization".to_string(), "Token dg_secret".to_string())] + ); + } + + #[test] + fn bearer_helper_surfaces_missing_credential_loudly() { + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + // Empty literal source resolves to None — should bubble up as + // CliError::Auth like the underlying Header variant. + let err = WsAuth::bearer(literal("")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("empty cred should error"); + assert!(matches!(err, CliError::Auth(_))); + } + + #[test] + fn token_helper_does_not_double_prefix_already_prefixed_value() { + // If a customer mistakenly passes "Token foo" to `WsAuth::token`, + // they get "Token Token foo" — documented surprise; we don't + // try to detect "already prefixed" since that would be fragile. + // This test is explicit so future refactors don't accidentally + // start stripping prefixes (which would also be wrong). + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + WsAuth::token(literal("Token already-prefixed")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers[0].1, + "Token Token already-prefixed", + "token() always prepends — by design" + ); + } + + #[test] + fn other_variants_skip_merge_into_first_message() { + let mut msg = serde_json::json!({"text": "hi"}); + WsAuth::Header("xi-api-key".into(), literal("k")) + .merge_into_first_message(&mut msg) + .unwrap(); + WsAuth::QueryParam("auth".into(), literal("k")) + .merge_into_first_message(&mut msg) + .unwrap(); + WsAuth::None.merge_into_first_message(&mut msg).unwrap(); + // No mutation expected from any of these variants. + assert_eq!(msg, serde_json::json!({"text": "hi"})); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/websocket/client.rs b/seed/cli/query-parameters-openapi/github-npm/src/websocket/client.rs new file mode 100644 index 000000000000..0823a541ae36 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/websocket/client.rs @@ -0,0 +1,667 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `WebSocketClient` — async bidirectional WS client driven by an +//! [`OutputPipeline`](crate::formatter::OutputPipeline). See `mod.rs` +//! for the module-level overview. + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message}; + +use crate::error::CliError; +use crate::formatter::OutputPipeline; +use crate::http::HttpConfig; + +use super::auth::WsAuth; +use super::error::{classify_close_frame, map_handshake_error, map_stream_error}; + +/// Inbound-frame autoresponder. +/// +/// Called once per inbound JSON frame. Returning `Some(reply)` causes the +/// client to (a) send `reply` as an outbound text frame and (b) **elide** +/// the inbound frame from stdout — useful for application-level ping/pong +/// where the inbound is protocol overhead, not user-visible payload. +/// Returning `None` lets the inbound flow through to +/// [`OutputPipeline::emit`]. +/// +/// Write your own closure for app-level ping/pong or any other +/// inbound-frame responder pattern your API requires. +/// +/// # Stateful autoresponders +/// +/// The closure is `Fn`, not `FnMut`, because the recv loop borrows it by +/// shared reference. If you need state (counter, throttle, per-event-id +/// dedupe), reach for interior mutability: +/// +/// ```ignore +/// use std::sync::atomic::{AtomicU64, Ordering}; +/// let count = std::sync::Arc::new(AtomicU64::new(0)); +/// let count_inner = count.clone(); +/// let responder: AutoResponder = std::sync::Arc::new(move |frame| { +/// count_inner.fetch_add(1, Ordering::Relaxed); +/// /* ... */ +/// None +/// }); +/// ``` +/// +/// Naïve `let mut n = 0; Arc::new(move |f| { n += 1; ... })` fails to +/// compile — the compiler error points at the closure body, not the +/// trait bound, which is easy to misread. +pub type AutoResponder = Arc Option + Send + Sync>; + +/// Configuration for a single WS connection. +pub struct WsConfig { + /// Connect URL (`wss://...` for TLS, `ws://...` for plaintext mocks). + pub url: String, + /// Where the credential goes (query / header / first-message / none). + pub auth: WsAuth, + /// Optional autoresponder. See [`AutoResponder`]. + pub auto_responder: Option, + /// Output pipeline applied to each inbound frame the autoresponder + /// did *not* claim. Pass via [`OutputPipeline::from_matches`] from + /// the custom-command handler so `--format` (and future + /// `--jq`/`--fields`/`--template`) flow through automatically. + pub output_pipeline: OutputPipeline, + /// If true, forward stdin lines as outbound text frames. EOF on stdin + /// triggers a clean WS Close(1000) and exit 0. + pub stdin_input: bool, + /// Validate each stdin line as JSON before sending. Invalid lines are + /// written to stderr as a warning and dropped (the connection is *not* + /// terminated). Default `true`. Set false only when the wire protocol + /// is non-JSON. + pub stdin_validate_json: bool, + /// JSON keys to recursively elide from each inbound frame before + /// emitting. Use to strip base64 audio blobs that would otherwise + /// flood a terminal. + pub strip_audio_keys: Vec, + /// Hint string woven into mid-stream / abnormal-close error messages. + /// Defaults to a generic "check auth, network, keepalive/timeout" + /// nudge; override when wiring an API with a more specific common + /// failure mode. + pub abnormal_close_hint: String, +} + +impl WsConfig { + /// Build a minimal config with no auth and a default pipeline. Useful + /// for in-process mock tests; production callers always fill in the + /// auth + autoresponder + stdin fields. + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + auth: WsAuth::None, + auto_responder: None, + output_pipeline: OutputPipeline::default(), + stdin_input: false, + stdin_validate_json: true, + strip_audio_keys: Vec::new(), + abnormal_close_hint: super::error::ABNORMAL_CLOSE_HINT.to_string(), + } + } + +} + +/// A connected WS client ready to send and receive frames. +pub struct WebSocketClient { + stream: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + config: WsConfig, + /// Tracks whether [`WebSocketClient::send`] has run yet. Used to + /// merge [`WsAuth::FirstMessage`] into the first outbound frame. + first_send_done: bool, +} + +impl WebSocketClient { + /// Connect to a WS endpoint, applying auth and reading TLS knobs from + /// `http_config`. + /// + /// Honored in v1: + /// - `_CONNECT_TIMEOUT_SECS` — applied as a handshake deadline. + /// + /// Resolved but not yet wired to the tungstenite connector + /// (deferred — misconfigurations still surface as a `CliError` at + /// `resolve()` time, before the handshake is attempted): + /// - `_CA_BUNDLE` / `_EXTRA_CA_CERTS` / `SSL_CERT_FILE` + /// - `_INSECURE` / `_INSECURE_SKIP_VERIFY` + /// - `_PROXY` / `_NO_PROXY` + /// + /// Not applicable to streaming transports: + /// - `_TIMEOUT_SECS` — bounds total request lifetime for the + /// reqwest path; a streaming WS connection has no defined "total + /// lifetime" so the value is ignored here. Use + /// `_CONNECT_TIMEOUT_SECS` for the handshake deadline. + /// + /// Default trust roots come from whichever TLS backend the feature + /// gate selects (`native-tls` reads the OS keychain; `rustls` uses + /// Mozilla's bundled webpki roots). + pub async fn connect( + mut config: WsConfig, + http_config: &HttpConfig, + ) -> Result { + // Resolve transport config up front. Even though v1 doesn't yet + // translate CA bundle / insecure into a tungstenite Connector, + // calling resolve() surfaces a misconfigured CA path immediately + // rather than after a confusing TLS error during handshake. + let resolved = http_config.resolve()?; + + // Apply URL/header auth. FirstMessage is deferred to first send(). + let mut url = config.url.clone(); + let mut headers: Vec<(String, String)> = Vec::new(); + config.auth.apply_to_url_and_headers(&mut url, &mut headers)?; + + // Build the handshake request. Using IntoClientRequest on a parsed + // URI gets us all the required WS handshake headers + // (Sec-WebSocket-Key/Version/Upgrade); we then layer our custom + // headers on top. + let uri: tokio_tungstenite::tungstenite::http::Uri = url.parse().map_err(|e| { + CliError::Validation(format!("invalid WebSocket URL `{url}`: {e}")) + })?; + let mut request = uri + .into_client_request() + .map_err(map_handshake_error)?; + for (name, value) in &headers { + let header_value = HeaderValue::from_str(value).map_err(|e| { + CliError::Validation(format!( + "WebSocket header `{name}` contains invalid characters: {e}" + )) + })?; + let header_name: tokio_tungstenite::tungstenite::http::HeaderName = + name.parse().map_err(|e| { + CliError::Validation(format!("invalid WebSocket header name `{name}`: {e}")) + })?; + request.headers_mut().insert(header_name, header_value); + } + + // Sync the URL on the WsConfig with what we actually connected to, + // so anything downstream that reads it (logging, error messages) + // reflects the post-auth-apply form. + config.url = url; + + // Connect, with optional handshake deadline. + let connect_fut = tokio_tungstenite::connect_async(request); + let connect_result = if let Some(deadline) = resolved.connect_timeout { + tokio::time::timeout(deadline, connect_fut).await.map_err(|_| { + CliError::Other(anyhow::anyhow!( + "WebSocket handshake timed out after {}s", + deadline.as_secs(), + )) + })? + } else { + connect_fut.await + }; + + let (stream, _response) = connect_result.map_err(map_handshake_error)?; + Ok(Self { + stream, + config, + first_send_done: false, + }) + } + + /// Send a JSON value as a WS text frame. Applies + /// [`WsAuth::FirstMessage`] merging on the very first send, then + /// becomes a plain serialize-and-send. + pub async fn send(&mut self, msg: &Value) -> Result<(), CliError> { + let mut to_send = msg.clone(); + if !self.first_send_done { + self.config.auth.merge_into_first_message(&mut to_send)?; + self.first_send_done = true; + } + let text = serde_json::to_string(&to_send).map_err(|e| { + CliError::Validation(format!("failed to serialize WS frame: {e}")) + })?; + let hint = self.config.abnormal_close_hint.clone(); + self.stream + .send(Message::Text(text)) + .await + .map_err(|e| map_stream_error(e, &hint)) + } + + /// Send raw bytes as a WS binary frame. + /// + /// Required by APIs that ship PCM audio (or any other binary payload) + /// on the wire. Callers typically drive this from their own + /// audio-capture loop (`cpal` mic, file reader, etc.) rather than from + /// the stdin path — stdin forwarding stays JSON-text only in v1 + /// (see ADR-0002 follow-ups). + /// + /// # `WsAuth::FirstMessage` interaction + /// + /// `FirstMessage` auth merges the credential into the first outbound + /// JSON frame. Binary frames have no JSON object to merge into, so + /// calling `send_binary` as the *very first* outbound when `FirstMessage` + /// auth is configured silently drops the credential. We error loudly + /// instead: send a JSON frame first (typically a per-API "configure + /// session" message that *should* carry the credential), then call + /// `send_binary` for audio chunks. + pub async fn send_binary(&mut self, bytes: Vec) -> Result<(), CliError> { + if !self.first_send_done && matches!(self.config.auth, WsAuth::FirstMessage(_, _)) { + return Err(CliError::Validation( + "WebSocket: send_binary called before any send() with WsAuth::FirstMessage \ + configured — the auth credential would never reach the server. Send your \ + session-init JSON frame via `send(...)` first; binary frames after." + .into(), + )); + } + let hint = self.config.abnormal_close_hint.clone(); + self.stream + .send(Message::Binary(bytes)) + .await + .map_err(|e| map_stream_error(e, &hint)) + } + + /// Run the recv loop until either `shutdown` fires or the server + /// closes the connection. On graceful shutdown / server `Close(1000)`, + /// returns `Ok(())`. Other terminations map per the matrix in + /// [`super::error`]. + /// + /// `shutdown` is intentionally a generic future rather than a + /// `tokio_util::sync::CancellationToken` — keeps the dep surface + /// small, and lets tests pass a `oneshot::Receiver` without dragging + /// the SIGINT machinery into unit tests. Production wires this to + /// [`tokio::signal::ctrl_c`] via [`Self::run_recv_loop`]. + pub async fn run_until_shutdown(self, shutdown: F) -> Result<(), CliError> + where + F: std::future::Future + Send + Unpin, + { + let WebSocketClient { + stream, + config, + first_send_done, + } = self; + let (mut sink, mut source) = stream.split(); + + let stdin_input = config.stdin_input; + let stdin_validate_json = config.stdin_validate_json; + let abnormal_hint = config.abnormal_close_hint.clone(); + // Keep the first-send bookkeeping live across the loop so the + // stdin branch can honor `WsAuth::FirstMessage` — without this, + // a caller combining `stdin_input = true` with `FirstMessage` + // auth would have the auth field silently dropped from the first + // outbound frame. + let mut first_send_done = first_send_done; + + // Bounded channel: stdin reader → recv loop. Bound is 64; when + // full, the reader blocks on `tx.send`, propagating backpressure + // back through the OS pipe buffer to the user's writer side. + // The `_stdin_tx_keepalive` binding holds the sender alive when + // we're not spawning a reader — without it the rx would return + // `None` on first recv, which the select! arm interprets as + // EOF (= clean shutdown) and exits immediately. Combined with + // the `if stdin_input` guard below this is belt-and-braces. + let (stdin_tx, mut stdin_rx) = mpsc::channel::(64); + let _stdin_tx_keepalive; + let stdin_handle = if stdin_input { + _stdin_tx_keepalive = None; + Some(tokio::spawn(stdin_reader_task(stdin_tx, stdin_validate_json))) + } else { + _stdin_tx_keepalive = Some(stdin_tx); + None + }; + + // Use the owned `Stdout` rather than a `StdoutLock`. Holding a + // lock across the recv loop's await points blocks any other + // thread that tries to write to stdout — and `StdoutLock` isn't + // `Send`, so the future itself wouldn't be `Send` either, which + // breaks `tokio::spawn`. `Stdout::write_all` locks internally + // per call, which is the right granularity for our throughput. + let mut stdout = std::io::stdout(); + let pipeline = config.output_pipeline.clone(); + let auto_responder = config.auto_responder.clone(); + let strip_keys: Vec = config.strip_audio_keys.clone(); + + let mut shutdown = shutdown; + + let exit_reason: Result<(), CliError> = loop { + tokio::select! { + // Bias toward shutdown — if a Ctrl+C fires the same + // instant as a frame arrives, the user expects the close + // path to win. + biased; + _ = &mut shutdown => { + break Ok(()); + } + line = stdin_rx.recv(), if stdin_input => { + match line { + Some(text) => { + // If `WsAuth::FirstMessage` is configured and + // we haven't sent yet, parse → merge → re-serialize + // so the auth credential lands in the very first + // outbound frame. Lines after the first ship as-is + // (matching `WebSocketClient::send`'s contract that + // FirstMessage applies only once per connection). + let to_send = if !first_send_done + && matches!(config.auth, WsAuth::FirstMessage(_, _)) + { + match serde_json::from_str::(&text) { + Ok(mut v) => { + if let Err(e) = + config.auth.merge_into_first_message(&mut v) + { + break Err(e); + } + match serde_json::to_string(&v) { + Ok(s) => s, + Err(e) => { + break Err(CliError::Validation(format!( + "failed to re-serialize first stdin \ + frame after merging FirstMessage \ + auth: {e}" + ))); + } + } + } + Err(e) => { + // FirstMessage auth requires merging + // into a JSON object — a non-JSON first + // stdin line breaks the contract loudly + // rather than silently dropping creds. + break Err(CliError::Validation(format!( + "FirstMessage auth requires the first stdin \ + frame to be valid JSON (got parse error: \ + {e}). If your wire protocol allows non-JSON \ + frames, call `client.send(...)` once with \ + the auth-bearing frame before \ + `run_until_shutdown`." + ))); + } + } + } else { + text + }; + first_send_done = true; + if let Err(e) = sink.send(Message::Text(to_send)).await { + break Err(map_stream_error(e, &abnormal_hint)); + } + } + None => { + // stdin EOF — clean exit per resolution sheet. + // The Close(1000) frame is sent after the loop + // unwinds (`exit_reason.is_ok()` branch below). + break Ok(()); + } + } + } + msg = source.next() => { + let result = handle_inbound( + msg, + &mut sink, + &auto_responder, + &pipeline, + &strip_keys, + &mut stdout, + &abnormal_hint, + ).await; + match result { + FrameDisposition::Continue => continue, + FrameDisposition::Stop(r) => break r, + } + } + } + }; + + // Send Close(1000) on graceful exit. We swallow the error from + // close() because the connection may already be closed (server + // initiated the close, network is gone, etc.) — `exit_reason` + // is the authoritative outcome. + // + // Note: when the server initiated the close, tungstenite has + // already queued the echo internally before our `Message::Close` + // reaches `sink.send`. Tungstenite's close-state machine treats + // user-side `send(Close)` as a no-op outside the Active state, + // so this is *not* a double-frame on the wire — just a wasted + // method call. Cheap and keeps the source readable. + if exit_reason.is_ok() { + let _ = tokio::time::timeout( + Duration::from_secs(2), + sink.send(Message::Close(Some(CloseFrame { + code: CloseCode::Normal, + reason: "".into(), + }))), + ) + .await; + } + + // Abort the stdin reader task. NOTE: `abort()` does NOT unwind a + // blocking read inside `tokio::io::stdin()` — the underlying + // blocking thread continues until the OS hands it a line or EOF. + // In `run_recv_loop` this is fine because the process is about + // to exit and the OS reclaims everything. Future stdin-driven + // *tests* will need a fake stdin (e.g. a custom `AsyncRead` + // injected via a future `WsConfig.stdin_source` field) to avoid + // leaking a blocking thread per test. + if let Some(handle) = stdin_handle { + handle.abort(); + } + + exit_reason + } + + /// Convenience wrapper that runs the recv loop until either + /// [`tokio::signal::ctrl_c`] fires or the server closes. + pub async fn run_recv_loop(self) -> Result<(), CliError> { + // Wrap the signal future so its concrete unit-typed shape lines + // up with the `Future + Unpin` bound on + // `run_until_shutdown`. Box the future to satisfy Unpin without + // requiring callers to pin manually. + let shutdown = Box::pin(async { + let _ = tokio::signal::ctrl_c().await; + }); + self.run_until_shutdown(shutdown).await + } +} + +enum FrameDisposition { + Continue, + Stop(Result<(), CliError>), +} + +/// Single-frame handler — invoked once per item from the WS source. +/// Returns whether the loop should keep going or break with a result. +async fn handle_inbound( + msg: Option>, + sink: &mut futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, + >, + auto_responder: &Option, + pipeline: &OutputPipeline, + strip_keys: &[String], + stdout: &mut std::io::Stdout, + abnormal_hint: &str, +) -> FrameDisposition { + match msg { + None => FrameDisposition::Stop(Err(CliError::Other(anyhow::anyhow!( + "WebSocket stream ended without a close frame — {abnormal_hint}" + )))), + Some(Err(e)) => FrameDisposition::Stop(Err(map_stream_error(e, abnormal_hint))), + Some(Ok(Message::Close(frame))) => { + FrameDisposition::Stop(classify_close_frame(frame.as_ref(), abnormal_hint)) + } + // WS protocol-level Ping/Pong are auto-handled by tungstenite; we + // never see them as user-payload. Frame is also internal. None of + // them should emit to stdout. + Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => { + FrameDisposition::Continue + } + Some(Ok(Message::Binary(b))) => { + // v1: inbound binary frames are not emitted to stdout (most + // streaming APIs send JSON inbound and only accept binary + // outbound). Warn visibly so callers hitting an API that + // *does* stream binary back know their stream produced + // unprintable bytes — silence would look like a hung pipe. + eprintln!( + "warning: dropped {}-byte inbound WebSocket binary frame \ + (v1 does not emit binary inbound; plumb a handler via \ + WsConfig in a future release if your API needs this)", + b.len(), + ); + FrameDisposition::Continue + } + Some(Ok(Message::Text(text))) => { + // Parse as JSON. If parsing fails, treat as transport-level + // garbage from the server — surface it. + let value: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(e) => { + return FrameDisposition::Stop(Err(CliError::Other(anyhow::anyhow!( + "WebSocket received unparseable JSON: {e}: {}", + truncate(&text, 200), + )))); + } + }; + + // Autoresponder first: if it claims the frame, send the reply + // and elide. No emit. + if let Some(responder) = auto_responder { + if let Some(reply) = responder(&value) { + let reply_text = match serde_json::to_string(&reply) { + Ok(s) => s, + Err(e) => { + return FrameDisposition::Stop(Err(CliError::Other( + anyhow::anyhow!("autoresponder produced unserializable JSON: {e}"), + ))); + } + }; + if let Err(e) = sink.send(Message::Text(reply_text)).await { + return FrameDisposition::Stop(Err(map_stream_error(e, abnormal_hint))); + } + return FrameDisposition::Continue; + } + } + + // Strip audio-shaped keys before emit (recursive). + let to_emit = if strip_keys.is_empty() { + value + } else { + let mut v = value; + strip_keys_recursive(&mut v, strip_keys); + v + }; + + // Emit through the pipeline. `paginated=true` so each frame + // emits as compact NDJSON (one object per line). + if let Err(e) = pipeline.emit(stdout, &to_emit, true, false) { + return FrameDisposition::Stop(Err(CliError::Other(anyhow::anyhow!( + "failed to emit WebSocket frame: {e}" + )))); + } + FrameDisposition::Continue + } + } +} + +/// Recursively remove keys whose name matches any entry in `keys`. Walks +/// objects and arrays in place. Linear in the JSON value's node count. +fn strip_keys_recursive(value: &mut Value, keys: &[String]) { + match value { + Value::Object(map) => { + for k in keys { + map.remove(k); + } + for (_, v) in map.iter_mut() { + strip_keys_recursive(v, keys); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + strip_keys_recursive(v, keys); + } + } + _ => {} + } +} + +/// Stdin reader task: pushes validated lines onto the bounded sender. +/// When stdin EOFs, drops the sender so the main loop sees `None` on +/// its next `recv()` and exits cleanly. Blank lines silently skipped; +/// invalid JSON warned to stderr and dropped (connection stays up). +async fn stdin_reader_task(tx: mpsc::Sender, validate_json: bool) { + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + loop { + match reader.next_line().await { + Ok(Some(line)) => { + if line.trim().is_empty() { + continue; + } + if validate_json { + if let Err(e) = serde_json::from_str::(&line) { + eprintln!( + "warning: stdin line is not valid JSON, dropping: {} ({})", + truncate(&line, 80), + e, + ); + continue; + } + } + if tx.send(line).await.is_err() { + // Receiver dropped — the recv loop has exited. Stop. + return; + } + } + Ok(None) => return, // EOF — drop tx by returning + Err(e) => { + eprintln!("warning: stdin read error: {e}"); + return; + } + } + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_keys_removes_top_level_and_nested() { + let mut value = serde_json::json!({ + "audio_base_64": "AAAA...", + "text": "hello", + "agent_response": { + "audio_base_64": "BBBB...", + "transcript": "world", + }, + "items": [ + {"audio_base_64": "CCCC...", "id": 1}, + {"audio_base_64": "DDDD...", "id": 2}, + ], + }); + strip_keys_recursive(&mut value, &["audio_base_64".to_string()]); + assert!(value.get("audio_base_64").is_none()); + assert_eq!(value["text"], "hello"); + assert!(value["agent_response"].get("audio_base_64").is_none()); + assert_eq!(value["agent_response"]["transcript"], "world"); + assert!(value["items"][0].get("audio_base_64").is_none()); + assert!(value["items"][1].get("audio_base_64").is_none()); + assert_eq!(value["items"][0]["id"], 1); + } + + #[test] + fn strip_keys_noop_when_keys_absent() { + let mut value = serde_json::json!({"text": "hi", "n": 1}); + strip_keys_recursive(&mut value, &["audio_base_64".to_string()]); + assert_eq!(value, serde_json::json!({"text": "hi", "n": 1})); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/websocket/error.rs b/seed/cli/query-parameters-openapi/github-npm/src/websocket/error.rs new file mode 100644 index 000000000000..8e6d5a317252 --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/websocket/error.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket failure → [`CliError`] mapping. +//! +//! # The matrix (v1) +//! +//! | Phase | Failure mode | `CliError` | Exit | +//! |---|---|---|---| +//! | handshake | DNS / TCP refused / reset | `Other` | 5 | +//! | handshake | TLS cert error | `Other` | 5 | +//! | handshake | 401 / 403 Upgrade rejected | `Auth` | 2 | +//! | handshake | 404 / wrong URL | `Discovery` | 4 | +//! | handshake | 5xx | `Api { code, .. }` | 1 | +//! | mid-stream | server `Close(1000)` Normal Closure | `Ok(())` | **0** | +//! | mid-stream | server `Close(1001..=1015)` abnormal | `Other` (hint included) | 5 | +//! | mid-stream | TCP drop / read timeout / inactivity | `Other` (hint included) | 5 | +//! | local | bad URL given to [`WsConfig::url`](super::WsConfig) | `Validation` | 3 | +//! | local | unparseable JSON from server | `Other` | 5 | +//! +//! The abnormal-close hint nudges users toward the most common failure +//! mode: auth / network / app-level keepalive misses. + +use tokio_tungstenite::tungstenite; + +use crate::error::CliError; + +/// Default hint appended to abnormal-close errors. API-neutral by +/// design — it's the message a user of *any* WS-using CLI should +/// understand. Override per-CLI by setting +/// [`super::WsConfig::abnormal_close_hint`] with API-specific guidance. +pub const ABNORMAL_CLOSE_HINT: &str = + "connection ended abnormally; check auth, network, and the API's keepalive/timeout requirements"; + +/// Map a `tungstenite::Error` raised during the handshake phase to a +/// [`CliError`] following the matrix above. Public so an external caller +/// implementing its own handshake wrapper (e.g. for unit-testing the +/// matrix in isolation) can reuse the mapping. +pub fn map_handshake_error(err: tungstenite::Error) -> CliError { + use tungstenite::Error as TE; + + match err { + TE::Http(response) => { + // The HTTP-status-bearing handshake failure: the server + // accepted the TCP connection but rejected the Upgrade. + let status = response.status().as_u16(); + // Best-effort body capture for the error message. Tungstenite + // exposes it as `Option>`. + let body = response + .into_body() + .and_then(|b| String::from_utf8(b).ok()) + .unwrap_or_default(); + match status { + 401 | 403 => CliError::Auth(format!( + "WebSocket upgrade rejected with {status}: {}", + truncate(&body, 200), + )), + 404 => CliError::Discovery(format!( + "WebSocket endpoint not found (404): {}", + truncate(&body, 200), + )), + 500..=599 => CliError::Api { + code: status, + message: format!("WebSocket upgrade failed: {}", truncate(&body, 200)), + reason: "wsHandshakeServerError".into(), + }, + _ => CliError::Other(anyhow::anyhow!( + "WebSocket upgrade failed with status {status}: {}", + truncate(&body, 200), + )), + } + } + TE::Url(e) => { + // tungstenite couldn't even parse / route the URL — caller + // gave us garbage. + CliError::Validation(format!("invalid WebSocket URL: {e}")) + } + // Everything else (Io, Tls, ConnectionClosed before negotiation, + // protocol violations during the upgrade) is transport-shaped. + other => CliError::Other(anyhow::anyhow!("WebSocket handshake failed: {other}")), + } +} + +/// Map a `tungstenite::Error` raised mid-stream (after handshake) to a +/// [`CliError`]. Always returns an `Err`; the recv loop maps `Ok` paths +/// (clean close 1000, polite close 1001) directly. `hint` is the message +/// the user should investigate — pass the WS config's +/// [`super::WsConfig::abnormal_close_hint`] (or the default). +pub(crate) fn map_stream_error(err: tungstenite::Error, hint: &str) -> CliError { + use tungstenite::Error as TE; + + match err { + TE::ConnectionClosed | TE::AlreadyClosed => CliError::Other(anyhow::anyhow!( + "WebSocket connection closed unexpectedly — {hint}" + )), + TE::Io(e) => CliError::Other(anyhow::anyhow!( + "WebSocket I/O error mid-stream: {e} — {hint}" + )), + other => CliError::Other(anyhow::anyhow!( + "WebSocket protocol error mid-stream: {other}" + )), + } +} + +/// Classify a server-initiated close frame. +/// +/// Returns `Ok(())` for **success-shaped** closures: +/// - `1000 Normal Closure` — protocol-correct end-of-session. +/// - `1001 Going Away` — peer is leaving (page navigation, server +/// shutdown, *or* session-cap expiry like a long-running session +/// hitting a server-side hard limit). For long-running sessions this +/// is the polite way to say "we're done"; treating it as an error +/// would cause shell pipelines to spuriously fail on a clean +/// end-of-session. +/// +/// Returns `Err` for everything else, with `hint` woven into the message +/// when supplied. `hint` is what the user should investigate; pass +/// [`ABNORMAL_CLOSE_HINT`] for the generic default, or supply an +/// API-specific string (see [`super::WsConfig::abnormal_close_hint`]). +pub(crate) fn classify_close_frame( + frame: Option<&tungstenite::protocol::CloseFrame<'_>>, + hint: &str, +) -> Result<(), CliError> { + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + + let Some(frame) = frame else { + // No close frame at all — the peer just hung up. Treat as abnormal. + return Err(CliError::Other(anyhow::anyhow!( + "WebSocket peer closed without a close frame — {hint}" + ))); + }; + match frame.code { + CloseCode::Normal => Ok(()), + CloseCode::Away => { + // 1001 "Going Away" — log to stderr so the user sees that + // the session ended for a benign reason, but don't fail the + // exit code. + let reason_suffix = if frame.reason.is_empty() { + String::new() + } else { + format!(" ({})", frame.reason) + }; + eprintln!( + "websocket: session ended with code 1001 going away{reason_suffix}" + ); + Ok(()) + } + _ => { + let code: u16 = frame.code.into(); + Err(CliError::Other(anyhow::anyhow!( + "WebSocket closed with code {code}{} — {hint}", + if frame.reason.is_empty() { + String::new() + } else { + format!(" ({})", frame.reason) + }, + ))) + } + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + // Truncate on a char boundary for safety; the body may be UTF-8 + // and slicing in the middle of a multibyte sequence panics. + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio_tungstenite::tungstenite::protocol::{CloseFrame, frame::coding::CloseCode}; + use std::borrow::Cow; + + fn frame(code: u16, reason: &'static str) -> CloseFrame<'static> { + CloseFrame { + code: CloseCode::from(code), + reason: Cow::Borrowed(reason), + } + } + + #[test] + fn close_1000_is_ok() { + assert!(classify_close_frame(Some(&frame(1000, "")), ABNORMAL_CLOSE_HINT).is_ok()); + } + + #[test] + fn close_1001_going_away_is_ok() { + // 1001 = peer is leaving (page nav, server shutdown, session-cap + // expiry). Treated as a clean end-of-session for long-running + // sessions that hit a server-side hard limit and similar + // "polite hangup" patterns. + assert!(classify_close_frame(Some(&frame(1001, "session cap")), ABNORMAL_CLOSE_HINT).is_ok()); + } + + #[test] + fn close_1006_is_err_with_hint() { + let err = classify_close_frame(Some(&frame(1006, "")), ABNORMAL_CLOSE_HINT).unwrap_err(); + assert!(err.to_string().contains("1006")); + assert!(err.to_string().contains(ABNORMAL_CLOSE_HINT)); + } + + #[test] + fn close_with_reason_includes_reason_in_message() { + let err = classify_close_frame(Some(&frame(1011, "internal error")), ABNORMAL_CLOSE_HINT) + .unwrap_err(); + assert!(err.to_string().contains("internal error")); + } + + #[test] + fn missing_close_frame_is_abnormal_err() { + let err = classify_close_frame(None, ABNORMAL_CLOSE_HINT).unwrap_err(); + assert!(err.to_string().contains(ABNORMAL_CLOSE_HINT)); + } + + #[test] + fn custom_hint_replaces_default_in_message() { + let custom = "custom hint: check KeepAlive cadence + audio format"; + let err = classify_close_frame(Some(&frame(1006, "")), custom).unwrap_err(); + assert!(err.to_string().contains(custom)); + assert!(!err.to_string().contains(ABNORMAL_CLOSE_HINT), + "default hint should NOT appear when a custom one was passed"); + } + + #[test] + fn handshake_url_error_maps_to_validation() { + let err = map_handshake_error(tungstenite::Error::Url( + tungstenite::error::UrlError::NoHostName, + )); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn truncate_respects_char_boundary() { + // U+1F600 is 4 bytes in UTF-8. Truncating at byte 2 would split it. + let s = "ab😀cd"; + let truncated = truncate(s, 3); + // Should fall back to a char boundary at or before 3. + assert!(truncated.starts_with("ab")); + } +} diff --git a/seed/cli/query-parameters-openapi/github-npm/src/websocket/mod.rs b/seed/cli/query-parameters-openapi/github-npm/src/websocket/mod.rs new file mode 100644 index 000000000000..090000eef2ac --- /dev/null +++ b/seed/cli/query-parameters-openapi/github-npm/src/websocket/mod.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket bidirectional client. +//! +//! Used by custom commands that need to graft a long-lived bidirectional +//! connection onto the CLI (realtime streaming, conversational APIs, +//! etc.). The recv loop emits each inbound JSON frame through +//! [`crate::formatter::OutputPipeline`] so format / color / future +//! jq/fields/template flags compose for free. +//! +//! # Composition with [`AppContext`](crate::openapi::AppContext) +//! +//! Custom-command handlers are synchronous, but the WS client is async. +//! Bridge with the same `block_in_place` + `Handle::current().block_on(...)` +//! pattern that [`AppContext::execute`](crate::openapi::AppContext::execute) +//! uses internally — see [`WebSocketClient::connect`] for an example. +//! +//! # Auth +//! +//! `WsAuth::{QueryParam, Header, FirstMessage}` each take an +//! [`AuthCredentialSource`](crate::auth::AuthCredentialSource) directly — +//! the WS module does **not** call into [`AuthProvider`](crate::auth::AuthProvider) +//! because that surface is reqwest-shaped. See +//! `docs/adr/0001-auth-provider-no-cred-extraction.md`. +//! +//! # TLS +//! +//! `WebSocketClient::connect` honors compile-time roots from +//! `CliApp::extra_root_cert` and resolves the same env vars as the +//! reqwest path via [`HttpConfig::resolve`](crate::http::HttpConfig::resolve) +//! — `_CA_BUNDLE`, `_INSECURE`, `_CONNECT_TIMEOUT_SECS`. +//! Proxy support (`_PROXY`) is not implemented in v1; document it as +//! a follow-up. +//! +//! # Graceful shutdown +//! +//! [`WebSocketClient::run_until_shutdown`] takes any future. Production +//! wires it to [`tokio::signal::ctrl_c`] via the convenience wrapper +//! [`WebSocketClient::run_recv_loop`]; tests wire it to a `oneshot` +//! receiver. + +mod auth; +mod client; +mod error; + +pub use auth::WsAuth; +pub use client::{AutoResponder, WebSocketClient, WsConfig}; +pub use error::map_handshake_error; diff --git a/seed/cli/query-parameters-openapi/no-custom-config/Cargo.lock b/seed/cli/query-parameters-openapi/no-custom-config/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/Cargo.lock +++ b/seed/cli/query-parameters-openapi/no-custom-config/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/query-parameters-openapi/no-custom-config/Cargo.toml b/seed/cli/query-parameters-openapi/no-custom-config/Cargo.toml index 5d3bcf79c003..a679b3c5223e 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/Cargo.toml +++ b/seed/cli/query-parameters-openapi/no-custom-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/schemaless-request-body-examples/Cargo.lock b/seed/cli/schemaless-request-body-examples/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/schemaless-request-body-examples/Cargo.lock +++ b/seed/cli/schemaless-request-body-examples/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/schemaless-request-body-examples/Cargo.toml b/seed/cli/schemaless-request-body-examples/Cargo.toml index 94ab1d5b8cf0..30ec3cf676f6 100644 --- a/seed/cli/schemaless-request-body-examples/Cargo.toml +++ b/seed/cli/schemaless-request-body-examples/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/seed.yml b/seed/cli/seed.yml index 5df39b73eb63..340a236ab4f3 100644 --- a/seed/cli/seed.yml +++ b/seed/cli/seed.yml @@ -60,6 +60,18 @@ fixtures: query-parameters-openapi: - customConfig: null outputFolder: no-custom-config + # Github output mode with npm publish info — verifies that the + # generator emits `.github/workflows/ci.yml` and stamps the + # resolved version into Cargo.toml. + - customConfig: null + outputFolder: github-npm + outputMode: github + outputVersion: "1.0.0" + publishConfig: + type: npm + registryUrl: https://registry.npmjs.org + packageName: "@fern/query-parameters-openapi" + token: "${NPM_TOKEN}" # Multi-spec fixture — declares two OpenAPI specs with NO namespaces. # `customConfig.binaryName` is REQUIRED for multi-spec (the generator # refuses to guess a single name across multiple titles). diff --git a/seed/cli/server-sent-events-openapi/Cargo.lock b/seed/cli/server-sent-events-openapi/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/server-sent-events-openapi/Cargo.lock +++ b/seed/cli/server-sent-events-openapi/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/server-sent-events-openapi/Cargo.toml b/seed/cli/server-sent-events-openapi/Cargo.toml index 4eb67d78b555..ee56ec8f1794 100644 --- a/seed/cli/server-sent-events-openapi/Cargo.toml +++ b/seed/cli/server-sent-events-openapi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/server-url-templating/Cargo.lock b/seed/cli/server-url-templating/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/server-url-templating/Cargo.lock +++ b/seed/cli/server-url-templating/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/server-url-templating/Cargo.toml b/seed/cli/server-url-templating/Cargo.toml index 249018f1b41c..978da0185e82 100644 --- a/seed/cli/server-url-templating/Cargo.toml +++ b/seed/cli/server-url-templating/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/url-form-encoded/Cargo.lock b/seed/cli/url-form-encoded/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/url-form-encoded/Cargo.lock +++ b/seed/cli/url-form-encoded/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/url-form-encoded/Cargo.toml b/seed/cli/url-form-encoded/Cargo.toml index 01fbd12afa41..70218b784696 100644 --- a/seed/cli/url-form-encoded/Cargo.toml +++ b/seed/cli/url-form-encoded/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/webhook-audience/Cargo.lock b/seed/cli/webhook-audience/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/webhook-audience/Cargo.lock +++ b/seed/cli/webhook-audience/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/webhook-audience/Cargo.toml b/seed/cli/webhook-audience/Cargo.toml index a2d9a8619d3e..67c7b55b8774 100644 --- a/seed/cli/webhook-audience/Cargo.toml +++ b/seed/cli/webhook-audience/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" diff --git a/seed/cli/x-fern-default/Cargo.lock b/seed/cli/x-fern-default/Cargo.lock index a5a694a2abd8..666fd8812728 100644 --- a/seed/cli/x-fern-default/Cargo.lock +++ b/seed/cli/x-fern-default/Cargo.lock @@ -344,7 +344,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" dependencies = [ "anyhow", "base64", diff --git a/seed/cli/x-fern-default/Cargo.toml b/seed/cli/x-fern-default/Cargo.toml index d9a209bc11db..eed4751f7d66 100644 --- a/seed/cli/x-fern-default/Cargo.toml +++ b/seed/cli/x-fern-default/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fern-cli-sdk" -version = "0.18.1" +version = "0.0.0" edition = "2021" description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" license = "Apache-2.0" From 15014e8379d0ae4921a2490d5a047a212a7cd882 Mon Sep 17 00:00:00 2001 From: jsklan <100491078+jsklan@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:53:20 -0400 Subject: [PATCH 09/14] Revert "chore(cli-generator): rename Docker image from fernapi/fern-cli to fernapi/fern-cli-generator" (#16200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "chore(cli-generator): rename Docker image from fernapi/fern-cli to fe…" This reverts commit 89c3c569f3721e5d53f8110a26aec0824ebe3611. --- generators/cli/package.json | 4 ++-- generators/cli/sdk/docs/DESIGN.md | 2 +- packages/cli/cli-v2/src/sdk/config/converter/constants.ts | 2 +- packages/cli/cli/changes/5.25.0/add-cli-generator.yml | 2 +- packages/cli/cli/changes/5.37.0/cli-generator-support.yml | 2 +- .../cli/changes/unreleased/rename-cli-generator-image.yml | 6 ------ packages/cli/cli/versions.yml | 4 ++-- .../src/generators-yml/GeneratorName.ts | 2 +- .../cli/generation/ir-migrations/src/generatorVersionMap.ts | 2 +- .../src/__test__/LocalTaskHandler.snippetCopy.test.ts | 2 +- .../local-workspace-runner/src/__test__/rawSpecs.test.ts | 2 +- .../local-workspace-runner/src/constants.ts | 2 +- seed/cli/seed.yml | 6 +++--- .../fern/apis/cli-multi-spec-namespaced/generators.yml | 2 +- test-definitions/fern/apis/cli-multi-spec/generators.yml | 2 +- 15 files changed, 18 insertions(+), 24 deletions(-) delete mode 100644 packages/cli/cli/changes/unreleased/rename-cli-generator-image.yml diff --git a/generators/cli/package.json b/generators/cli/package.json index a94b23470bc7..e4de04010473 100644 --- a/generators/cli/package.json +++ b/generators/cli/package.json @@ -27,8 +27,8 @@ "compile": "tsc", "compile:debug": "tsc --sourceMap", "dist:cli": "node build.mjs", - "dockerTagLatest": "docker build -f ./Dockerfile -t fernapi/fern-cli-generator:latest .", - "dockerTagVersion": "turbo run dist:cli --filter . && docker build -f ./Dockerfile -t fernapi/fern-cli-generator:${0} .", + "dockerTagLatest": "docker build -f ./Dockerfile -t fernapi/fern-cli:latest .", + "dockerTagVersion": "turbo run dist:cli --filter . && docker build -f ./Dockerfile -t fernapi/fern-cli:${0} .", "test": "vitest --passWithNoTests --run" }, "devDependencies": { diff --git a/generators/cli/sdk/docs/DESIGN.md b/generators/cli/sdk/docs/DESIGN.md index 6ded9822f8d2..4230c2525a3b 100644 --- a/generators/cli/sdk/docs/DESIGN.md +++ b/generators/cli/sdk/docs/DESIGN.md @@ -429,7 +429,7 @@ The current prototype embeds a single spec at compile time. The full vision (out groups: cli: generators: - - name: fernapi/fern-cli-generator + - name: fernapi/fern-cli version: 0.1.0 config: cli-name: acme diff --git a/packages/cli/cli-v2/src/sdk/config/converter/constants.ts b/packages/cli/cli-v2/src/sdk/config/converter/constants.ts index 3264902b506c..acff55234819 100644 --- a/packages/cli/cli-v2/src/sdk/config/converter/constants.ts +++ b/packages/cli/cli-v2/src/sdk/config/converter/constants.ts @@ -66,7 +66,7 @@ export const DOCKER_IMAGE_TO_GENERATOR_ID: Record = { "fernapi/fern-swift-model": "swift-model", "fernapi/fern-postman": "postman", "fernapi/fern-openapi": "openapi", - "fernapi/fern-cli-generator": "cli" + "fernapi/fern-cli": "cli" }; /** diff --git a/packages/cli/cli/changes/5.25.0/add-cli-generator.yml b/packages/cli/cli/changes/5.25.0/add-cli-generator.yml index 452ac55d60d3..7877edfd98d6 100644 --- a/packages/cli/cli/changes/5.25.0/add-cli-generator.yml +++ b/packages/cli/cli/changes/5.25.0/add-cli-generator.yml @@ -1,5 +1,5 @@ - summary: | - Register the new `fernapi/fern-cli-generator` generator in the CLI configuration. + Register the new `fernapi/fern-cli` generator in the CLI configuration. type: feat - summary: | Pass raw API spec files (OAS, overrides, overlays, protobuf, etc.) to generators via Docker mount. diff --git a/packages/cli/cli/changes/5.37.0/cli-generator-support.yml b/packages/cli/cli/changes/5.37.0/cli-generator-support.yml index e3c0f4352959..b84907e10897 100644 --- a/packages/cli/cli/changes/5.37.0/cli-generator-support.yml +++ b/packages/cli/cli/changes/5.37.0/cli-generator-support.yml @@ -7,7 +7,7 @@ and `GraphQLSpec.namespace`). Enables generators that opt into `generatorWantsSpecs` to route multi-spec workspaces by their workspace-declared namespace instead of inferring one from the - runner-assigned filename. The new `fernapi/fern-cli-generator` generator + runner-assigned filename. The new `fernapi/fern-cli` generator uses this to emit `.spec_under("", ...)` in the generated `main.rs` when the user namespaces their specs. type: feat diff --git a/packages/cli/cli/changes/unreleased/rename-cli-generator-image.yml b/packages/cli/cli/changes/unreleased/rename-cli-generator-image.yml deleted file mode 100644 index f247a89b2bfd..000000000000 --- a/packages/cli/cli/changes/unreleased/rename-cli-generator-image.yml +++ /dev/null @@ -1,6 +0,0 @@ -# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json - -- summary: | - Rename the CLI generator Docker image from `fernapi/fern-cli` to - `fernapi/fern-cli-generator`. - type: chore diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index 0d4f7e2b2ca4..d89d894e907e 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -251,7 +251,7 @@ and `GraphQLSpec.namespace`). Enables generators that opt into `generatorWantsSpecs` to route multi-spec workspaces by their workspace-declared namespace instead of inferring one from the - runner-assigned filename. The new `fernapi/fern-cli-generator` generator + runner-assigned filename. The new `fernapi/fern-cli` generator uses this to emit `.spec_under("", ...)` in the generated `main.rs` when the user namespaces their specs. type: feat @@ -715,7 +715,7 @@ - version: 5.25.0 changelogEntry: - summary: | - Register the new `fernapi/fern-cli-generator` generator in the CLI configuration. + Register the new `fernapi/fern-cli` generator in the CLI configuration. type: feat createdAt: "2026-05-14" irVersion: 66 diff --git a/packages/cli/configuration-loader/src/generators-yml/GeneratorName.ts b/packages/cli/configuration-loader/src/generators-yml/GeneratorName.ts index 569275dd173c..39918af8369f 100644 --- a/packages/cli/configuration-loader/src/generators-yml/GeneratorName.ts +++ b/packages/cli/configuration-loader/src/generators-yml/GeneratorName.ts @@ -26,7 +26,7 @@ export const GeneratorName = { STOPLIGHT: "fernapi/fern-stoplight", POSTMAN: "fernapi/fern-postman", OPENAPI_PYTHON_CLIENT: "fernapi/openapi-python-client", - CLI: "fernapi/fern-cli-generator" + CLI: "fernapi/fern-cli" } as const; export type GeneratorName = (typeof GeneratorName)[keyof typeof GeneratorName]; diff --git a/packages/cli/generation/ir-migrations/src/generatorVersionMap.ts b/packages/cli/generation/ir-migrations/src/generatorVersionMap.ts index f6e010e1b081..14bf4dc2f0c8 100644 --- a/packages/cli/generation/ir-migrations/src/generatorVersionMap.ts +++ b/packages/cli/generation/ir-migrations/src/generatorVersionMap.ts @@ -5,7 +5,7 @@ import { MINIMUM_SUPPORTED_IR_VERSION } from "./constants.js"; export const GENERATOR_MINIMUM_VERSIONS: Record = { - "fernapi/fern-cli-generator": "0.0.1", + "fernapi/fern-cli": "0.0.1", "fernapi/fern-csharp-model": "0.0.2", "fernapi/fern-csharp-sdk": "0.5.0", "fernapi/fern-express": "0.17.3", diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/LocalTaskHandler.snippetCopy.test.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/LocalTaskHandler.snippetCopy.test.ts index 2627ecc167c2..754eecc74c6c 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/LocalTaskHandler.snippetCopy.test.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/LocalTaskHandler.snippetCopy.test.ts @@ -7,7 +7,7 @@ import { copySnippetJsonIfNonEmpty } from "../LocalTaskHandler.js"; /** * Unit coverage for the empty-stub skip behavior introduced when the - * `fernapi/fern-cli-generator` generator started shipping outputs without + * `fernapi/fern-cli` generator started shipping outputs without * per-endpoint snippets. `runGenerator` pre-creates an empty * `snippet.json` in the workspace tmp dir; if we blindly copy it into * the user's output every generator without a snippet emission leaves a diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts index 9d0536db890b..a227121f90b9 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts @@ -882,7 +882,7 @@ describe("filterSpec", () => { /** * Coverage for the per-entry `namespace` field added to * `RawSpecsManifestEntry`. Downstream generators (initially - * `fernapi/fern-cli-generator`) rely on this field to route multi-spec + * `fernapi/fern-cli`) rely on this field to route multi-spec * workspaces by their `generators.yml`-declared namespace rather than * inferring one from the runner-assigned filename. */ diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts index db199336028b..dedf5848cb87 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/constants.ts @@ -37,7 +37,7 @@ export const DEFAULT_NODE_DEBUG_PORT = "9229"; * Generators that receive pre-processed raw API spec files mounted into their * Docker container. Add new generator names here as they opt in. */ -const GENERATORS_WANTING_SPECS: ReadonlySet = new Set(["fernapi/fern-cli-generator"]); +const GENERATORS_WANTING_SPECS: ReadonlySet = new Set(["fernapi/fern-cli"]); export function generatorWantsSpecs(generatorName: string): boolean { return GENERATORS_WANTING_SPECS.has(generatorName); diff --git a/seed/cli/seed.yml b/seed/cli/seed.yml index 340a236ab4f3..ee1f30e060c4 100644 --- a/seed/cli/seed.yml +++ b/seed/cli/seed.yml @@ -1,6 +1,6 @@ irVersion: v67 displayName: CLI -image: fernapi/fern-cli-generator +image: fernapi/fern-cli changelogLocation: ../../generators/cli/versions.yml buildScripts: @@ -16,12 +16,12 @@ publish: - pnpm turbo run dist:cli --filter @fern-api/cli-generator docker: file: ./generators/cli/Dockerfile - image: fernapi/fern-cli-generator + image: fernapi/fern-cli context: ./generators/cli test: docker: - image: fernapi/fern-cli-generator:latest + image: fernapi/fern-cli:latest command: pnpm turbo run dockerTagLatest --filter @fern-api/cli-generator local: workingDirectory: generators/cli diff --git a/test-definitions/fern/apis/cli-multi-spec-namespaced/generators.yml b/test-definitions/fern/apis/cli-multi-spec-namespaced/generators.yml index 40b53a6b3027..22e5eb38949e 100644 --- a/test-definitions/fern/apis/cli-multi-spec-namespaced/generators.yml +++ b/test-definitions/fern/apis/cli-multi-spec-namespaced/generators.yml @@ -13,6 +13,6 @@ api: groups: cli: generators: - - name: fernapi/fern-cli-generator + - name: fernapi/fern-cli version: latest ir-version: latest diff --git a/test-definitions/fern/apis/cli-multi-spec/generators.yml b/test-definitions/fern/apis/cli-multi-spec/generators.yml index 91a1404ec22d..61d39e9bfbbe 100644 --- a/test-definitions/fern/apis/cli-multi-spec/generators.yml +++ b/test-definitions/fern/apis/cli-multi-spec/generators.yml @@ -10,6 +10,6 @@ api: groups: cli: generators: - - name: fernapi/fern-cli-generator + - name: fernapi/fern-cli version: latest ir-version: latest From 69e6f59b7f7bb03eb3febef1835b2420e17dc588 Mon Sep 17 00:00:00 2001 From: Fern Support <126544928+fern-support@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:18:12 -0400 Subject: [PATCH 10/14] chore(seed): update all seed snapshots (#16192) Co-authored-by: jsklan <100491078+jsklan@users.noreply.github.com> --- .../allof-inline/Snippets/Example10.cs | 24 + .../allof-inline/Snippets/Example11.cs | 26 + .../allof-inline/Snippets/Example12.cs | 21 + .../allof-inline/Snippets/Example13.cs | 24 + seed/csharp-sdk/allof-inline/reference.md | 125 + seed/csharp-sdk/allof-inline/snippet.json | 24 + .../Unit/MockServer/CreatePlantTest.cs | 118 + .../Unit/MockServer/CreateTreeTest.cs | 112 + .../src/SeedApi/ISeedApiClient.cs | 18 + .../src/SeedApi/Requests/PlantPost.cs | 59 + .../allof-inline/src/SeedApi/SeedApiClient.cs | 184 + .../src/SeedApi/Types/PlantBase.cs | 52 + .../Types/PlantBaseWateringFrequency.cs | 123 + .../src/SeedApi/Types/PlantPostSunExposure.cs | 119 + .../Types/PlantPostWateringFrequency.cs | 123 + .../src/SeedApi/Types/PlantStrict.cs | 43 + .../src/SeedApi/Types/TreeBase.cs | 55 + .../src/SeedApi/Types/TreeDescribable.cs | 37 + .../src/SeedApi/Types/TreeIdentifiable.cs | 31 + .../src/SeedApi/Types/TreeRecord.cs | 61 + seed/csharp-sdk/allof/Snippets/Example10.cs | 22 + seed/csharp-sdk/allof/Snippets/Example11.cs | 26 + seed/csharp-sdk/allof/Snippets/Example12.cs | 19 + seed/csharp-sdk/allof/Snippets/Example13.cs | 24 + seed/csharp-sdk/allof/reference.md | 116 + seed/csharp-sdk/allof/snippet.json | 24 + .../Unit/MockServer/CreatePlantTest.cs | 114 + .../Unit/MockServer/CreateTreeTest.cs | 103 + .../allof/src/SeedApi/ISeedApiClient.cs | 18 + .../allof/src/SeedApi/Requests/PlantPost.cs | 59 + .../allof/src/SeedApi/SeedApiClient.cs | 175 + .../allof/src/SeedApi/Types/PlantBase.cs | 52 + .../Types/PlantBaseWateringFrequency.cs | 123 + .../src/SeedApi/Types/PlantPostSunExposure.cs | 119 + .../allof/src/SeedApi/Types/PlantStrict.cs | 43 + .../allof/src/SeedApi/Types/TreeBase.cs | 55 + .../src/SeedApi/Types/TreeDescribable.cs | 37 + .../src/SeedApi/Types/TreeIdentifiable.cs | 31 + .../allof/src/SeedApi/Types/TreeRecord.cs | 61 + seed/go-sdk/allof-inline/client/client.go | 34 + seed/go-sdk/allof-inline/client/raw_client.go | 85 + .../dynamic-snippets/example10/snippet.go | 29 + .../dynamic-snippets/example11/snippet.go | 37 + .../dynamic-snippets/example12/snippet.go | 26 + .../dynamic-snippets/example13/snippet.go | 37 + seed/go-sdk/allof-inline/reference.md | 185 + seed/go-sdk/allof-inline/snippet.json | 22 + seed/go-sdk/allof-inline/types.go | 1609 ++++++-- seed/go-sdk/allof-inline/types_test.go | 3458 ++++++++++++++--- seed/go-sdk/allof/.fern/metadata.json | 3 +- seed/java-sdk/allof-inline/reference.md | 181 + seed/java-sdk/allof-inline/snippet.json | 26 + .../com/seed/api/AsyncRawSeedApiClient.java | 135 + .../java/com/seed/api/AsyncSeedApiClient.java | 31 + .../java/com/seed/api/RawSeedApiClient.java | 107 + .../main/java/com/seed/api/SeedApiClient.java | 31 + .../java/com/seed/api/requests/PlantPost.java | 409 ++ .../java/com/seed/api/types/PlantBase.java | 276 ++ .../api/types/PlantBaseWateringFrequency.java | 105 + .../seed/api/types/PlantPostSunExposure.java | 93 + .../api/types/PlantPostWateringFrequency.java | 105 + .../java/com/seed/api/types/PlantStrict.java | 195 + .../java/com/seed/api/types/TreeBase.java | 305 ++ .../com/seed/api/types/TreeDescribable.java | 140 + .../com/seed/api/types/TreeIdentifiable.java | 129 + .../java/com/seed/api/types/TreeRecord.java | 334 ++ .../src/main/java/com/snippets/Example10.java | 22 + .../src/main/java/com/snippets/Example11.java | 24 + .../src/main/java/com/snippets/Example12.java | 17 + .../src/main/java/com/snippets/Example13.java | 20 + seed/java-sdk/allof/.fern/metadata.json | 3 +- seed/openapi/allof-inline/openapi.yml | 290 ++ seed/openapi/allof/openapi.yml | 200 + seed/php-sdk/allof-inline/reference.md | 179 + .../allof-inline/src/Requests/PlantPost.php | 86 + seed/php-sdk/allof-inline/src/SeedClient.php | 101 + .../allof-inline/src/Types/PlantBase.php | 66 + .../src/Types/PlantBaseWateringFrequency.php | 11 + .../src/Types/PlantPostSunExposure.php | 10 + .../src/Types/PlantPostWateringFrequency.php | 11 + .../allof-inline/src/Types/PlantStrict.php | 50 + .../allof-inline/src/Types/TreeBase.php | 66 + .../src/Types/TreeDescribable.php | 42 + .../src/Types/TreeIdentifiable.php | 34 + .../allof-inline/src/Types/TreeRecord.php | 76 + .../dynamic-snippets/example10/snippet.php | 24 + .../dynamic-snippets/example11/snippet.php | 27 + .../dynamic-snippets/example12/snippet.php | 19 + .../dynamic-snippets/example13/snippet.php | 23 + seed/php-sdk/allof/reference.md | 135 + seed/php-sdk/allof/src/Requests/PlantPost.php | 59 + seed/php-sdk/allof/src/SeedClient.php | 101 + seed/php-sdk/allof/src/Traits/PlantBase.php | 27 + seed/php-sdk/allof/src/Traits/PlantStrict.php | 31 + seed/php-sdk/allof/src/Traits/TreeBase.php | 27 + .../allof/src/Traits/TreeDescribable.php | 24 + .../allof/src/Traits/TreeIdentifiable.php | 17 + seed/php-sdk/allof/src/Types/PlantBase.php | 51 + .../src/Types/PlantBaseWateringFrequency.php | 11 + .../allof/src/Types/PlantPostSunExposure.php | 10 + seed/php-sdk/allof/src/Types/PlantStrict.php | 50 + seed/php-sdk/allof/src/Types/TreeBase.php | 53 + .../allof/src/Types/TreeDescribable.php | 42 + .../allof/src/Types/TreeIdentifiable.php | 34 + seed/php-sdk/allof/src/Types/TreeRecord.php | 49 + .../dynamic-snippets/example10/snippet.php | 21 + .../dynamic-snippets/example11/snippet.php | 27 + .../dynamic-snippets/example12/snippet.php | 17 + .../dynamic-snippets/example13/snippet.php | 23 + seed/python-sdk/accept-header/poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../poetry.lock | 6 +- seed/python-sdk/alias/poetry.lock | 6 +- .../allof-inline/no-custom-config/poetry.lock | 6 +- .../no-custom-config/reference.md | 207 + .../no-custom-config/snippet.json | 26 + .../no-custom-config/src/seed/__init__.py | 27 + .../no-custom-config/src/seed/client.py | 301 ++ .../no-custom-config/src/seed/raw_client.py | 329 ++ .../src/seed/types/__init__.py | 27 + .../src/seed/types/plant_base.py | 46 + .../types/plant_base_watering_frequency.py | 5 + .../src/seed/types/plant_post_sun_exposure.py | 5 + .../types/plant_post_watering_frequency.py | 5 + .../src/seed/types/plant_strict.py | 32 + .../src/seed/types/tree_base.py | 45 + .../src/seed/types/tree_describable.py | 30 + .../src/seed/types/tree_identifiable.py | 22 + .../src/seed/types/tree_record.py | 47 + .../no-custom-config/tests/wire/test_.py | 27 + .../wiremock/wiremock-mappings.json | 54 +- .../no-custom-config/.fern/metadata.json | 3 +- .../allof/no-custom-config/poetry.lock | 6 +- seed/python-sdk/any-auth/poetry.lock | 6 +- .../poetry.lock | 6 +- .../python-sdk/api-wide-base-path/poetry.lock | 6 +- seed/python-sdk/audiences/poetry.lock | 6 +- .../poetry.lock | 6 +- .../with-wire-tests/poetry.lock | 6 +- seed/python-sdk/basic-auth/poetry.lock | 6 +- .../poetry.lock | 6 +- seed/python-sdk/bytes-download/poetry.lock | 6 +- seed/python-sdk/bytes-upload/poetry.lock | 6 +- .../poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../poetry.lock | 6 +- .../cli-multi-spec-namespaced/poetry.lock | 6 +- seed/python-sdk/cli-multi-spec/poetry.lock | 6 +- .../python-sdk/client-side-params/poetry.lock | 6 +- seed/python-sdk/content-type/poetry.lock | 6 +- .../cross-package-type-names/poetry.lock | 6 +- .../dollar-string-examples/poetry.lock | 6 +- seed/python-sdk/empty-clients/poetry.lock | 6 +- .../endpoint-security-auth/poetry.lock | 6 +- .../enum/no-custom-config/poetry.lock | 6 +- .../enum/real-enum-forward-compat/poetry.lock | 6 +- seed/python-sdk/enum/real-enum/poetry.lock | 6 +- seed/python-sdk/enum/strenum/poetry.lock | 6 +- seed/python-sdk/error-property/poetry.lock | 6 +- seed/python-sdk/errors/poetry.lock | 6 +- .../poetry.lock | 6 +- .../examples/client-filename/poetry.lock | 6 +- .../examples/legacy-wire-tests/poetry.lock | 6 +- .../examples/no-custom-config/poetry.lock | 6 +- .../examples/omit-fern-headers/poetry.lock | 6 +- seed/python-sdk/examples/readme/poetry.lock | 6 +- .../additional_init_exports/poetry.lock | 6 +- .../aliases_with_validation/poetry.lock | 6 +- .../aliases_without_validation/poetry.lock | 6 +- .../exhaustive/custom-transport/poetry.lock | 6 +- .../datetime-milliseconds/poetry.lock | 6 +- .../deps_with_min_python_version/poetry.lock | 165 +- .../exhaustive/eager-imports/poetry.lock | 6 +- .../exhaustive/extra_dependencies/poetry.lock | 6 +- .../extra_dev_dependencies/poetry.lock | 6 +- .../five-second-timeout/poetry.lock | 6 +- .../follow_redirects_by_default/poetry.lock | 6 +- .../exhaustive/import-paths/poetry.lock | 6 +- .../exhaustive/improved_imports/poetry.lock | 6 +- .../exhaustive/infinite-timeout/poetry.lock | 6 +- .../exhaustive/inline-path-params/poetry.lock | 6 +- .../inline_request_params/poetry.lock | 6 +- .../exhaustive/no-custom-config/poetry.lock | 6 +- .../output-directory-project-root/poetry.lock | 6 +- .../exhaustive/package-path/poetry.lock | 6 +- .../pydantic-extra-fields/poetry.lock | 6 +- .../pydantic-ignore-fields/poetry.lock | 6 +- .../pydantic-v1-with-utils/poetry.lock | 6 +- .../pydantic-v1-wrapped/poetry.lock | 6 +- .../exhaustive/pydantic-v1/poetry.lock | 6 +- .../pydantic-v2-wrapped/poetry.lock | 6 +- .../exhaustive/pyproject_extras/poetry.lock | 6 +- .../skip-pydantic-validation/poetry.lock | 6 +- .../exhaustive/union-utils/poetry.lock | 6 +- .../wire-tests-custom-client-name/poetry.lock | 6 +- seed/python-sdk/extends/poetry.lock | 6 +- seed/python-sdk/extra-properties/poetry.lock | 6 +- .../default-chunk-size/poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../file-upload-openapi/poetry.lock | 6 +- .../poetry.lock | 6 +- .../file-upload/no-custom-config/poetry.lock | 6 +- .../use_typeddict_requests/poetry.lock | 6 +- seed/python-sdk/folders/poetry.lock | 6 +- .../poetry.lock | 6 +- seed/python-sdk/header-auth/poetry.lock | 6 +- seed/python-sdk/http-head/poetry.lock | 6 +- .../idempotency-headers/poetry.lock | 6 +- seed/python-sdk/imdb/poetry.lock | 6 +- .../inferred-auth-explicit/poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- .../inferred-auth-implicit/poetry.lock | 6 +- seed/python-sdk/license/poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../literal/no-custom-config/poetry.lock | 6 +- .../use_typeddict_requests/poetry.lock | 6 +- seed/python-sdk/literals-unions/poetry.lock | 6 +- seed/python-sdk/mixed-case/poetry.lock | 6 +- .../poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- seed/python-sdk/multi-line-docs/poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- .../multi-url-environment/poetry.lock | 6 +- .../multiple-request-bodies/poetry.lock | 6 +- .../no-content-response/poetry.lock | 6 +- seed/python-sdk/no-environment/poetry.lock | 6 +- seed/python-sdk/no-retries/poetry.lock | 6 +- seed/python-sdk/null-type/poetry.lock | 6 +- .../nullable-allof-extends/poetry.lock | 6 +- seed/python-sdk/nullable-optional/poetry.lock | 6 +- .../nullable-request-body/poetry.lock | 6 +- .../nullable/no-custom-config/poetry.lock | 6 +- .../use-typeddict-requests/poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- .../oauth-client-credentials/poetry.lock | 6 +- seed/python-sdk/object/poetry.lock | 6 +- .../objects-with-imports/poetry.lock | 6 +- .../openapi-request-body-ref/poetry.lock | 6 +- seed/python-sdk/optional/poetry.lock | 6 +- seed/python-sdk/package-yml/poetry.lock | 6 +- seed/python-sdk/pagination-custom/poetry.lock | 6 +- .../pagination-uri-path/poetry.lock | 6 +- .../pagination/no-custom-config/poetry.lock | 6 +- .../poetry.lock | 6 +- .../page-index-semantics/poetry.lock | 6 +- seed/python-sdk/path-parameters/poetry.lock | 6 +- seed/python-sdk/plain-text/poetry.lock | 6 +- seed/python-sdk/property-access/poetry.lock | 6 +- seed/python-sdk/public-object/poetry.lock | 6 +- .../python-backslash-escape/poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../with-mypy-exclude/poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../with-positional-constructors/poetry.lock | 6 +- .../poetry.lock | 6 +- .../with-wire-tests/poetry.lock | 6 +- .../query-param-name-conflict/poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- .../python-sdk/request-parameters/poetry.lock | 6 +- seed/python-sdk/required-nullable/poetry.lock | 6 +- seed/python-sdk/reserved-keywords/poetry.lock | 6 +- seed/python-sdk/response-property/poetry.lock | 6 +- .../poetry.lock | 6 +- .../server-sent-event-examples/poetry.lock | 6 +- .../with-wire-tests/poetry.lock | 6 +- .../server-sent-events-resumable/poetry.lock | 6 +- .../with-wire-tests/poetry.lock | 6 +- .../no-custom-config/poetry.lock | 6 +- seed/python-sdk/simple-api/poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- .../streaming-parameter/poetry.lock | 6 +- .../streaming/no-custom-config/poetry.lock | 6 +- .../skip-pydantic-validation/poetry.lock | 6 +- seed/python-sdk/trace/poetry.lock | 6 +- .../poetry.lock | 6 +- .../undiscriminated-unions/poetry.lock | 6 +- .../union-query-parameters/poetry.lock | 6 +- .../unions-with-local-date/poetry.lock | 6 +- .../flatten-union-request-bodies/poetry.lock | 6 +- .../unions/no-custom-config/poetry.lock | 6 +- .../union-naming-v1-wire-tests/poetry.lock | 6 +- .../unions/union-naming-v1/poetry.lock | 6 +- .../python-sdk/unions/union-utils/poetry.lock | 6 +- seed/python-sdk/unknown/poetry.lock | 6 +- seed/python-sdk/url-form-encoded/poetry.lock | 6 +- .../validation/no-custom-config/poetry.lock | 6 +- .../with-defaults-parameters/poetry.lock | 6 +- .../validation/with-defaults/poetry.lock | 6 +- seed/python-sdk/variables/poetry.lock | 6 +- .../python-sdk/version-no-default/poetry.lock | 6 +- seed/python-sdk/version/poetry.lock | 6 +- seed/python-sdk/webhook-audience/poetry.lock | 6 +- seed/python-sdk/webhooks/poetry.lock | 6 +- .../websocket-bearer-auth/poetry.lock | 6 +- .../websocket-inferred-auth/poetry.lock | 6 +- .../websocket-multi-url/poetry.lock | 6 +- .../websocket/websocket-base/poetry.lock | 6 +- .../poetry.lock | 6 +- .../poetry.lock | 6 +- seed/python-sdk/x-fern-default/poetry.lock | 6 +- .../dynamic-snippets/example10/snippet.rb | 12 + .../dynamic-snippets/example11/snippet.rb | 14 + .../dynamic-snippets/example12/snippet.rb | 9 + .../dynamic-snippets/example13/snippet.rb | 12 + seed/ruby-sdk-v2/allof-inline/lib/seed.rb | 10 + .../allof-inline/lib/seed/client.rb | 70 + .../allof-inline/lib/seed/types/plant_base.rb | 17 + .../types/plant_base_watering_frequency.rb | 14 + .../allof-inline/lib/seed/types/plant_post.rb | 23 + .../lib/seed/types/plant_post_sun_exposure.rb | 13 + .../types/plant_post_watering_frequency.rb | 14 + .../lib/seed/types/plant_strict.rb | 13 + .../allof-inline/lib/seed/types/tree_base.rb | 17 + .../lib/seed/types/tree_describable.rb | 11 + .../lib/seed/types/tree_identifiable.rb | 9 + .../lib/seed/types/tree_record.rb | 19 + seed/ruby-sdk-v2/allof-inline/reference.md | 191 + .../dynamic-snippets/example10/snippet.rb | 10 + .../dynamic-snippets/example11/snippet.rb | 14 + .../dynamic-snippets/example12/snippet.rb | 5 + .../dynamic-snippets/example13/snippet.rb | 12 + seed/ruby-sdk-v2/allof/lib/seed.rb | 9 + seed/ruby-sdk-v2/allof/lib/seed/client.rb | 70 + .../allof/lib/seed/types/plant_base.rb | 17 + .../types/plant_base_watering_frequency.rb | 14 + .../allof/lib/seed/types/plant_post.rb | 23 + .../lib/seed/types/plant_post_sun_exposure.rb | 13 + .../allof/lib/seed/types/plant_strict.rb | 13 + .../allof/lib/seed/types/tree_base.rb | 17 + .../allof/lib/seed/types/tree_describable.rb | 11 + .../allof/lib/seed/types/tree_identifiable.rb | 9 + .../allof/lib/seed/types/tree_record.rb | 19 + seed/ruby-sdk-v2/allof/reference.md | 145 + .../dynamic-snippets/example10.rs | 25 + .../dynamic-snippets/example11.rs | 25 + .../dynamic-snippets/example12.rs | 21 + .../dynamic-snippets/example13.rs | 24 + seed/rust-sdk/allof-inline/reference.md | 191 + .../allof-inline/src/api/resources/mod.rs | 50 + .../allof-inline/src/api/types/mod.rs | 20 + .../allof-inline/src/api/types/plant_base.rs | 85 + .../types/plant_base_watering_frequency.rs | 50 + .../allof-inline/src/api/types/plant_post.rs | 125 + .../src/api/types/plant_post_sun_exposure.rs | 47 + .../types/plant_post_watering_frequency.rs | 50 + .../src/api/types/plant_strict.rs | 64 + .../allof-inline/src/api/types/tree_base.rs | 82 + .../src/api/types/tree_describable.rs | 46 + .../src/api/types/tree_identifiable.rs | 36 + .../allof-inline/src/api/types/tree_record.rs | 99 + seed/rust-sdk/allof-inline/src/core/mod.rs | 1 + .../src/core/number_serializers.rs | 177 + .../allof/dynamic-snippets/example10.rs | 25 + .../allof/dynamic-snippets/example11.rs | 25 + .../allof/dynamic-snippets/example12.rs | 22 + .../allof/dynamic-snippets/example13.rs | 27 + seed/rust-sdk/allof/reference.md | 152 + seed/rust-sdk/allof/src/api/resources/mod.rs | 50 + seed/rust-sdk/allof/src/api/types/mod.rs | 18 + .../allof/src/api/types/plant_base.rs | 58 + .../types/plant_base_watering_frequency.rs | 50 + .../allof/src/api/types/plant_post.rs | 120 + .../src/api/types/plant_post_sun_exposure.rs | 47 + .../allof/src/api/types/plant_strict.rs | 64 + .../rust-sdk/allof/src/api/types/tree_base.rs | 73 + .../allof/src/api/types/tree_describable.rs | 46 + .../allof/src/api/types/tree_identifiable.rs | 36 + .../allof/src/api/types/tree_record.rs | 48 + seed/rust-sdk/allof/src/core/mod.rs | 1 + .../allof/src/core/number_serializers.rs | 177 + .../allof-inline/Snippets/Example10.swift | 17 + .../allof-inline/Snippets/Example11.swift | 19 + .../allof-inline/Snippets/Example12.swift | 14 + .../allof-inline/Snippets/Example13.swift | 17 + .../allof-inline/Sources/ApiClient.swift | 26 + .../Sources/Requests/Requests+PlantPost.swift | 83 + .../Sources/Schemas/PlantBase.swift | 60 + .../Schemas/PlantBaseWateringFrequency.swift | 8 + .../Schemas/PlantPostSunExposure.swift | 8 + .../Schemas/PlantPostWateringFrequency.swift | 8 + .../Sources/Schemas/PlantStrict.swift | 47 + .../Sources/Schemas/TreeBase.swift | 61 + .../Sources/Schemas/TreeDescribable.swift | 40 + .../Sources/Schemas/TreeIdentifiable.swift | 33 + .../Sources/Schemas/TreeRecord.swift | 68 + seed/swift-sdk/allof-inline/reference.md | 153 + seed/swift-sdk/allof/Snippets/Example10.swift | 15 + seed/swift-sdk/allof/Snippets/Example11.swift | 19 + seed/swift-sdk/allof/Snippets/Example12.swift | 12 + seed/swift-sdk/allof/Snippets/Example13.swift | 17 + seed/swift-sdk/allof/Sources/ApiClient.swift | 26 + .../Sources/Requests/Requests+PlantPost.swift | 83 + .../allof/Sources/Schemas/PlantBase.swift | 60 + .../Schemas/PlantBaseWateringFrequency.swift | 8 + .../Schemas/PlantPostSunExposure.swift | 8 + .../allof/Sources/Schemas/PlantStrict.swift | 47 + .../allof/Sources/Schemas/TreeBase.swift | 61 + .../Sources/Schemas/TreeDescribable.swift | 40 + .../Sources/Schemas/TreeIdentifiable.swift | 33 + .../allof/Sources/Schemas/TreeRecord.swift | 68 + seed/swift-sdk/allof/reference.md | 149 + seed/ts-sdk/allof-inline/reference.md | 137 + seed/ts-sdk/allof-inline/snippet.json | 22 + seed/ts-sdk/allof-inline/src/Client.ts | 121 + .../src/api/client/requests/PlantPost.ts | 47 + .../src/api/client/requests/index.ts | 1 + .../allof-inline/src/api/types/PlantBase.ts | 23 + .../allof-inline/src/api/types/PlantStrict.ts | 10 + .../allof-inline/src/api/types/TreeBase.ts | 14 + .../src/api/types/TreeDescribable.ts | 8 + .../src/api/types/TreeIdentifiable.ts | 6 + .../allof-inline/src/api/types/TreeRecord.ts | 16 + .../allof-inline/src/api/types/index.ts | 6 + .../allof-inline/tests/wire/main.test.ts | 63 + seed/ts-sdk/allof/.fern/metadata.json | 3 +- 430 files changed, 18891 insertions(+), 1482 deletions(-) create mode 100644 seed/csharp-sdk/allof-inline/Snippets/Example10.cs create mode 100644 seed/csharp-sdk/allof-inline/Snippets/Example11.cs create mode 100644 seed/csharp-sdk/allof-inline/Snippets/Example12.cs create mode 100644 seed/csharp-sdk/allof-inline/Snippets/Example13.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi.Test/Unit/MockServer/CreatePlantTest.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi.Test/Unit/MockServer/CreateTreeTest.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi/Requests/PlantPost.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantBase.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantBaseWateringFrequency.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantPostSunExposure.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantPostWateringFrequency.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantStrict.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeBase.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeDescribable.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeIdentifiable.cs create mode 100644 seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeRecord.cs create mode 100644 seed/csharp-sdk/allof/Snippets/Example10.cs create mode 100644 seed/csharp-sdk/allof/Snippets/Example11.cs create mode 100644 seed/csharp-sdk/allof/Snippets/Example12.cs create mode 100644 seed/csharp-sdk/allof/Snippets/Example13.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi.Test/Unit/MockServer/CreatePlantTest.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi.Test/Unit/MockServer/CreateTreeTest.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi/Requests/PlantPost.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi/Types/PlantBase.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi/Types/PlantBaseWateringFrequency.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi/Types/PlantPostSunExposure.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi/Types/PlantStrict.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi/Types/TreeBase.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi/Types/TreeDescribable.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi/Types/TreeIdentifiable.cs create mode 100644 seed/csharp-sdk/allof/src/SeedApi/Types/TreeRecord.cs create mode 100644 seed/go-sdk/allof-inline/dynamic-snippets/example10/snippet.go create mode 100644 seed/go-sdk/allof-inline/dynamic-snippets/example11/snippet.go create mode 100644 seed/go-sdk/allof-inline/dynamic-snippets/example12/snippet.go create mode 100644 seed/go-sdk/allof-inline/dynamic-snippets/example13/snippet.go create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/seed/api/requests/PlantPost.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantBase.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantBaseWateringFrequency.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantPostSunExposure.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantPostWateringFrequency.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantStrict.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeBase.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeDescribable.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeIdentifiable.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeRecord.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/snippets/Example10.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/snippets/Example11.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/snippets/Example12.java create mode 100644 seed/java-sdk/allof-inline/src/main/java/com/snippets/Example13.java create mode 100644 seed/php-sdk/allof-inline/src/Requests/PlantPost.php create mode 100644 seed/php-sdk/allof-inline/src/Types/PlantBase.php create mode 100644 seed/php-sdk/allof-inline/src/Types/PlantBaseWateringFrequency.php create mode 100644 seed/php-sdk/allof-inline/src/Types/PlantPostSunExposure.php create mode 100644 seed/php-sdk/allof-inline/src/Types/PlantPostWateringFrequency.php create mode 100644 seed/php-sdk/allof-inline/src/Types/PlantStrict.php create mode 100644 seed/php-sdk/allof-inline/src/Types/TreeBase.php create mode 100644 seed/php-sdk/allof-inline/src/Types/TreeDescribable.php create mode 100644 seed/php-sdk/allof-inline/src/Types/TreeIdentifiable.php create mode 100644 seed/php-sdk/allof-inline/src/Types/TreeRecord.php create mode 100644 seed/php-sdk/allof-inline/src/dynamic-snippets/example10/snippet.php create mode 100644 seed/php-sdk/allof-inline/src/dynamic-snippets/example11/snippet.php create mode 100644 seed/php-sdk/allof-inline/src/dynamic-snippets/example12/snippet.php create mode 100644 seed/php-sdk/allof-inline/src/dynamic-snippets/example13/snippet.php create mode 100644 seed/php-sdk/allof/src/Requests/PlantPost.php create mode 100644 seed/php-sdk/allof/src/Traits/PlantBase.php create mode 100644 seed/php-sdk/allof/src/Traits/PlantStrict.php create mode 100644 seed/php-sdk/allof/src/Traits/TreeBase.php create mode 100644 seed/php-sdk/allof/src/Traits/TreeDescribable.php create mode 100644 seed/php-sdk/allof/src/Traits/TreeIdentifiable.php create mode 100644 seed/php-sdk/allof/src/Types/PlantBase.php create mode 100644 seed/php-sdk/allof/src/Types/PlantBaseWateringFrequency.php create mode 100644 seed/php-sdk/allof/src/Types/PlantPostSunExposure.php create mode 100644 seed/php-sdk/allof/src/Types/PlantStrict.php create mode 100644 seed/php-sdk/allof/src/Types/TreeBase.php create mode 100644 seed/php-sdk/allof/src/Types/TreeDescribable.php create mode 100644 seed/php-sdk/allof/src/Types/TreeIdentifiable.php create mode 100644 seed/php-sdk/allof/src/Types/TreeRecord.php create mode 100644 seed/php-sdk/allof/src/dynamic-snippets/example10/snippet.php create mode 100644 seed/php-sdk/allof/src/dynamic-snippets/example11/snippet.php create mode 100644 seed/php-sdk/allof/src/dynamic-snippets/example12/snippet.php create mode 100644 seed/php-sdk/allof/src/dynamic-snippets/example13/snippet.php create mode 100644 seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_base.py create mode 100644 seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_base_watering_frequency.py create mode 100644 seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_post_sun_exposure.py create mode 100644 seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_post_watering_frequency.py create mode 100644 seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_strict.py create mode 100644 seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_base.py create mode 100644 seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_describable.py create mode 100644 seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_identifiable.py create mode 100644 seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_record.py create mode 100644 seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example10/snippet.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example11/snippet.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example12/snippet.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example13/snippet.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_base.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_base_watering_frequency.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post_sun_exposure.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post_watering_frequency.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_strict.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_base.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_describable.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_identifiable.rb create mode 100644 seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_record.rb create mode 100644 seed/ruby-sdk-v2/allof/dynamic-snippets/example10/snippet.rb create mode 100644 seed/ruby-sdk-v2/allof/dynamic-snippets/example11/snippet.rb create mode 100644 seed/ruby-sdk-v2/allof/dynamic-snippets/example12/snippet.rb create mode 100644 seed/ruby-sdk-v2/allof/dynamic-snippets/example13/snippet.rb create mode 100644 seed/ruby-sdk-v2/allof/lib/seed/types/plant_base.rb create mode 100644 seed/ruby-sdk-v2/allof/lib/seed/types/plant_base_watering_frequency.rb create mode 100644 seed/ruby-sdk-v2/allof/lib/seed/types/plant_post.rb create mode 100644 seed/ruby-sdk-v2/allof/lib/seed/types/plant_post_sun_exposure.rb create mode 100644 seed/ruby-sdk-v2/allof/lib/seed/types/plant_strict.rb create mode 100644 seed/ruby-sdk-v2/allof/lib/seed/types/tree_base.rb create mode 100644 seed/ruby-sdk-v2/allof/lib/seed/types/tree_describable.rb create mode 100644 seed/ruby-sdk-v2/allof/lib/seed/types/tree_identifiable.rb create mode 100644 seed/ruby-sdk-v2/allof/lib/seed/types/tree_record.rb create mode 100644 seed/rust-sdk/allof-inline/dynamic-snippets/example10.rs create mode 100644 seed/rust-sdk/allof-inline/dynamic-snippets/example11.rs create mode 100644 seed/rust-sdk/allof-inline/dynamic-snippets/example12.rs create mode 100644 seed/rust-sdk/allof-inline/dynamic-snippets/example13.rs create mode 100644 seed/rust-sdk/allof-inline/src/api/types/plant_base.rs create mode 100644 seed/rust-sdk/allof-inline/src/api/types/plant_base_watering_frequency.rs create mode 100644 seed/rust-sdk/allof-inline/src/api/types/plant_post.rs create mode 100644 seed/rust-sdk/allof-inline/src/api/types/plant_post_sun_exposure.rs create mode 100644 seed/rust-sdk/allof-inline/src/api/types/plant_post_watering_frequency.rs create mode 100644 seed/rust-sdk/allof-inline/src/api/types/plant_strict.rs create mode 100644 seed/rust-sdk/allof-inline/src/api/types/tree_base.rs create mode 100644 seed/rust-sdk/allof-inline/src/api/types/tree_describable.rs create mode 100644 seed/rust-sdk/allof-inline/src/api/types/tree_identifiable.rs create mode 100644 seed/rust-sdk/allof-inline/src/api/types/tree_record.rs create mode 100644 seed/rust-sdk/allof-inline/src/core/number_serializers.rs create mode 100644 seed/rust-sdk/allof/dynamic-snippets/example10.rs create mode 100644 seed/rust-sdk/allof/dynamic-snippets/example11.rs create mode 100644 seed/rust-sdk/allof/dynamic-snippets/example12.rs create mode 100644 seed/rust-sdk/allof/dynamic-snippets/example13.rs create mode 100644 seed/rust-sdk/allof/src/api/types/plant_base.rs create mode 100644 seed/rust-sdk/allof/src/api/types/plant_base_watering_frequency.rs create mode 100644 seed/rust-sdk/allof/src/api/types/plant_post.rs create mode 100644 seed/rust-sdk/allof/src/api/types/plant_post_sun_exposure.rs create mode 100644 seed/rust-sdk/allof/src/api/types/plant_strict.rs create mode 100644 seed/rust-sdk/allof/src/api/types/tree_base.rs create mode 100644 seed/rust-sdk/allof/src/api/types/tree_describable.rs create mode 100644 seed/rust-sdk/allof/src/api/types/tree_identifiable.rs create mode 100644 seed/rust-sdk/allof/src/api/types/tree_record.rs create mode 100644 seed/rust-sdk/allof/src/core/number_serializers.rs create mode 100644 seed/swift-sdk/allof-inline/Snippets/Example10.swift create mode 100644 seed/swift-sdk/allof-inline/Snippets/Example11.swift create mode 100644 seed/swift-sdk/allof-inline/Snippets/Example12.swift create mode 100644 seed/swift-sdk/allof-inline/Snippets/Example13.swift create mode 100644 seed/swift-sdk/allof-inline/Sources/Requests/Requests+PlantPost.swift create mode 100644 seed/swift-sdk/allof-inline/Sources/Schemas/PlantBase.swift create mode 100644 seed/swift-sdk/allof-inline/Sources/Schemas/PlantBaseWateringFrequency.swift create mode 100644 seed/swift-sdk/allof-inline/Sources/Schemas/PlantPostSunExposure.swift create mode 100644 seed/swift-sdk/allof-inline/Sources/Schemas/PlantPostWateringFrequency.swift create mode 100644 seed/swift-sdk/allof-inline/Sources/Schemas/PlantStrict.swift create mode 100644 seed/swift-sdk/allof-inline/Sources/Schemas/TreeBase.swift create mode 100644 seed/swift-sdk/allof-inline/Sources/Schemas/TreeDescribable.swift create mode 100644 seed/swift-sdk/allof-inline/Sources/Schemas/TreeIdentifiable.swift create mode 100644 seed/swift-sdk/allof-inline/Sources/Schemas/TreeRecord.swift create mode 100644 seed/swift-sdk/allof/Snippets/Example10.swift create mode 100644 seed/swift-sdk/allof/Snippets/Example11.swift create mode 100644 seed/swift-sdk/allof/Snippets/Example12.swift create mode 100644 seed/swift-sdk/allof/Snippets/Example13.swift create mode 100644 seed/swift-sdk/allof/Sources/Requests/Requests+PlantPost.swift create mode 100644 seed/swift-sdk/allof/Sources/Schemas/PlantBase.swift create mode 100644 seed/swift-sdk/allof/Sources/Schemas/PlantBaseWateringFrequency.swift create mode 100644 seed/swift-sdk/allof/Sources/Schemas/PlantPostSunExposure.swift create mode 100644 seed/swift-sdk/allof/Sources/Schemas/PlantStrict.swift create mode 100644 seed/swift-sdk/allof/Sources/Schemas/TreeBase.swift create mode 100644 seed/swift-sdk/allof/Sources/Schemas/TreeDescribable.swift create mode 100644 seed/swift-sdk/allof/Sources/Schemas/TreeIdentifiable.swift create mode 100644 seed/swift-sdk/allof/Sources/Schemas/TreeRecord.swift create mode 100644 seed/ts-sdk/allof-inline/src/api/client/requests/PlantPost.ts create mode 100644 seed/ts-sdk/allof-inline/src/api/types/PlantBase.ts create mode 100644 seed/ts-sdk/allof-inline/src/api/types/PlantStrict.ts create mode 100644 seed/ts-sdk/allof-inline/src/api/types/TreeBase.ts create mode 100644 seed/ts-sdk/allof-inline/src/api/types/TreeDescribable.ts create mode 100644 seed/ts-sdk/allof-inline/src/api/types/TreeIdentifiable.ts create mode 100644 seed/ts-sdk/allof-inline/src/api/types/TreeRecord.ts diff --git a/seed/csharp-sdk/allof-inline/Snippets/Example10.cs b/seed/csharp-sdk/allof-inline/Snippets/Example10.cs new file mode 100644 index 000000000000..a7e919b10028 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/Snippets/Example10.cs @@ -0,0 +1,24 @@ +using SeedApi; + +public partial class Examples +{ + public async Task Example10() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.CreatePlantAsync( + new PlantPost { + Species = "species", + Family = "family", + Genus = "genus", + CommonName = "commonName", + WateringFrequency = PlantPostWateringFrequency.Daily, + SunExposure = PlantPostSunExposure.Full + } + ); + } + +} diff --git a/seed/csharp-sdk/allof-inline/Snippets/Example11.cs b/seed/csharp-sdk/allof-inline/Snippets/Example11.cs new file mode 100644 index 000000000000..1654094ba006 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/Snippets/Example11.cs @@ -0,0 +1,26 @@ +using SeedApi; + +public partial class Examples +{ + public async Task Example11() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.CreatePlantAsync( + new PlantPost { + Species = "species", + Family = "family", + Genus = "genus", + CommonName = "commonName", + WateringFrequency = PlantPostWateringFrequency.Daily, + SunExposure = PlantPostSunExposure.Full, + PlantedAt = DateOnly.Parse("2023-01-15"), + SoilType = "soilType" + } + ); + } + +} diff --git a/seed/csharp-sdk/allof-inline/Snippets/Example12.cs b/seed/csharp-sdk/allof-inline/Snippets/Example12.cs new file mode 100644 index 000000000000..5f931fcc6816 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/Snippets/Example12.cs @@ -0,0 +1,21 @@ +using SeedApi; + +public partial class Examples +{ + public async Task Example12() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.CreateTreeAsync( + new TreeRecord { + Id = "id", + TreeName = "treeName", + TreeSpecies = "treeSpecies" + } + ); + } + +} diff --git a/seed/csharp-sdk/allof-inline/Snippets/Example13.cs b/seed/csharp-sdk/allof-inline/Snippets/Example13.cs new file mode 100644 index 000000000000..38556d707348 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/Snippets/Example13.cs @@ -0,0 +1,24 @@ +using SeedApi; + +public partial class Examples +{ + public async Task Example13() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.CreateTreeAsync( + new TreeRecord { + Id = "id", + TreeName = "treeName", + TreeDescription = "treeDescription", + TreeSpecies = "treeSpecies", + HeightInFeet = 1.1, + PlantedDate = DateOnly.Parse("2023-01-15") + } + ); + } + +} diff --git a/seed/csharp-sdk/allof-inline/reference.md b/seed/csharp-sdk/allof-inline/reference.md index eab7f3a438d2..214066fb6eec 100644 --- a/seed/csharp-sdk/allof-inline/reference.md +++ b/seed/csharp-sdk/allof-inline/reference.md @@ -160,3 +160,128 @@ await client.GetOrganizationAsync(); +

client.CreatePlantAsync(PlantPost { ... }) -> WithRawResponseTask<PlantStrict> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.CreatePlantAsync( + new PlantPost + { + Species = "species", + Family = "family", + Genus = "genus", + CommonName = "commonName", + WateringFrequency = PlantPostWateringFrequency.Daily, + SunExposure = PlantPostSunExposure.Full, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `PlantPost` + +
+
+
+
+ + +
+
+
+ +
client.CreateTreeAsync(TreeRecord { ... }) -> WithRawResponseTask<TreeRecord> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.CreateTreeAsync( + new TreeRecord + { + Id = "id", + TreeName = "treeName", + TreeSpecies = "treeSpecies", + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TreeRecord` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/csharp-sdk/allof-inline/snippet.json b/seed/csharp-sdk/allof-inline/snippet.json index 56a6d70194b2..e4a3683f2860 100644 --- a/seed/csharp-sdk/allof-inline/snippet.json +++ b/seed/csharp-sdk/allof-inline/snippet.json @@ -60,6 +60,30 @@ "type": "csharp", "client": "using SeedApi;\n\nvar client = new SeedApiClient();\nawait client.GetOrganizationAsync();\n" } + }, + { + "example_identifier": null, + "id": { + "path": "/plants", + "method": "POST", + "identifier_override": "endpoint_.createPlant" + }, + "snippet": { + "type": "csharp", + "client": "using SeedApi;\n\nvar client = new SeedApiClient();\nawait client.CreatePlantAsync(\n new PlantPost\n {\n Species = \"species\",\n Family = \"family\",\n Genus = \"genus\",\n CommonName = \"commonName\",\n WateringFrequency = PlantPostWateringFrequency.Daily,\n SunExposure = PlantPostSunExposure.Full,\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/trees", + "method": "POST", + "identifier_override": "endpoint_.createTree" + }, + "snippet": { + "type": "csharp", + "client": "using SeedApi;\n\nvar client = new SeedApiClient();\nawait client.CreateTreeAsync(\n new TreeRecord\n {\n Id = \"id\",\n TreeName = \"treeName\",\n TreeSpecies = \"treeSpecies\",\n }\n);\n" + } } ] } \ No newline at end of file diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi.Test/Unit/MockServer/CreatePlantTest.cs b/seed/csharp-sdk/allof-inline/src/SeedApi.Test/Unit/MockServer/CreatePlantTest.cs new file mode 100644 index 000000000000..ebe5516d6a42 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi.Test/Unit/MockServer/CreatePlantTest.cs @@ -0,0 +1,118 @@ +using NUnit.Framework; +using SeedApi; +using SeedApi.Test.Utils; + +namespace SeedApi.Test.Unit.MockServer; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class CreatePlantTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest_1() + { + const string requestJson = """ + { + "species": "species", + "family": "family", + "genus": "genus", + "commonName": "commonName", + "wateringFrequency": "daily", + "sunExposure": "full", + "plantedAt": "2023-01-15", + "soilType": "soilType" + } + """; + + const string mockResponse = """ + { + "species": "species", + "family": "family", + "genus": "genus" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/plants") + .WithHeader("Content-Type", "application/json") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.CreatePlantAsync( + new PlantPost + { + Species = "species", + Family = "family", + Genus = "genus", + CommonName = "commonName", + WateringFrequency = PlantPostWateringFrequency.Daily, + SunExposure = PlantPostSunExposure.Full, + PlantedAt = new DateOnly(2023, 1, 15), + SoilType = "soilType", + } + ); + JsonAssert.AreEqual(response, mockResponse); + } + + [NUnit.Framework.Test] + public async Task MockServerTest_2() + { + const string requestJson = """ + { + "species": "species", + "family": "family", + "genus": "genus", + "commonName": "commonName", + "wateringFrequency": "daily", + "sunExposure": "full" + } + """; + + const string mockResponse = """ + { + "species": "species", + "family": "family", + "genus": "genus" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/plants") + .WithHeader("Content-Type", "application/json") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.CreatePlantAsync( + new PlantPost + { + Species = "species", + Family = "family", + Genus = "genus", + CommonName = "commonName", + WateringFrequency = PlantPostWateringFrequency.Daily, + SunExposure = PlantPostSunExposure.Full, + } + ); + JsonAssert.AreEqual(response, mockResponse); + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi.Test/Unit/MockServer/CreateTreeTest.cs b/seed/csharp-sdk/allof-inline/src/SeedApi.Test/Unit/MockServer/CreateTreeTest.cs new file mode 100644 index 000000000000..6a0cad71e24a --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi.Test/Unit/MockServer/CreateTreeTest.cs @@ -0,0 +1,112 @@ +using NUnit.Framework; +using SeedApi; +using SeedApi.Test.Utils; + +namespace SeedApi.Test.Unit.MockServer; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class CreateTreeTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest_1() + { + const string requestJson = """ + { + "id": "id", + "treeName": "treeName", + "treeDescription": "treeDescription", + "treeSpecies": "treeSpecies", + "heightInFeet": 1.1, + "plantedDate": "2023-01-15" + } + """; + + const string mockResponse = """ + { + "id": "id", + "treeName": "treeName", + "treeDescription": "treeDescription", + "treeSpecies": "treeSpecies", + "heightInFeet": 1.1, + "plantedDate": "2023-01-15" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/trees") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.CreateTreeAsync( + new TreeRecord + { + Id = "id", + TreeName = "treeName", + TreeDescription = "treeDescription", + TreeSpecies = "treeSpecies", + HeightInFeet = 1.1, + PlantedDate = new DateOnly(2023, 1, 15), + } + ); + JsonAssert.AreEqual(response, mockResponse); + } + + [NUnit.Framework.Test] + public async Task MockServerTest_2() + { + const string requestJson = """ + { + "id": "id", + "treeName": "treeName", + "treeSpecies": "treeSpecies" + } + """; + + const string mockResponse = """ + { + "id": "id", + "treeName": "treeName", + "treeDescription": "treeDescription", + "treeSpecies": "treeSpecies", + "heightInFeet": 1.1, + "plantedDate": "2023-01-15" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/trees") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.CreateTreeAsync( + new TreeRecord + { + Id = "id", + TreeName = "treeName", + TreeSpecies = "treeSpecies", + } + ); + JsonAssert.AreEqual(response, mockResponse); + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/ISeedApiClient.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/ISeedApiClient.cs index 4b1eb3e650c8..2d0b14cff320 100644 --- a/seed/csharp-sdk/allof-inline/src/SeedApi/ISeedApiClient.cs +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/ISeedApiClient.cs @@ -28,4 +28,22 @@ WithRawResponseTask GetOrganizationAsync( RequestOptions? options = null, CancellationToken cancellationToken = default ); + + /// + /// Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + /// + WithRawResponseTask CreatePlantAsync( + PlantPost request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + /// + WithRawResponseTask CreateTreeAsync( + TreeRecord request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); } diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/Requests/PlantPost.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/Requests/PlantPost.cs new file mode 100644 index 000000000000..d94b5a4416d7 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/Requests/PlantPost.cs @@ -0,0 +1,59 @@ +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record PlantPost +{ + /// + /// The botanical species name. + /// + [JsonPropertyName("species")] + public required string Species { get; set; } + + /// + /// The botanical family. + /// + [JsonPropertyName("family")] + public required string Family { get; set; } + + /// + /// The botanical genus. + /// + [JsonPropertyName("genus")] + public required string Genus { get; set; } + + /// + /// The common name of the plant. + /// + [JsonPropertyName("commonName")] + public required string CommonName { get; set; } + + [JsonPropertyName("wateringFrequency")] + public required PlantPostWateringFrequency WateringFrequency { get; set; } + + /// + /// Required sun exposure level. + /// + [JsonPropertyName("sunExposure")] + public required PlantPostSunExposure SunExposure { get; set; } + + /// + /// Date the plant was planted. + /// + [JsonPropertyName("plantedAt")] + public DateOnly? PlantedAt { get; set; } + + /// + /// Preferred soil type. + /// + [JsonPropertyName("soilType")] + public string? SoilType { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/SeedApiClient.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/SeedApiClient.cs index f8a55884e719..178c4c803ed9 100644 --- a/seed/csharp-sdk/allof-inline/src/SeedApi/SeedApiClient.cs +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/SeedApiClient.cs @@ -358,6 +358,139 @@ private async Task> GetOrganizationAsyncCore( } } + private async Task> CreatePlantAsyncCore( + PlantPost request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var _headers = await new SeedApi.Core.HeadersBuilder.Builder() + .Add(_client.Options.Headers) + .Add(_client.Options.AdditionalHeaders) + .Add(options?.AdditionalHeaders) + .BuildAsync() + .ConfigureAwait(false); + var response = await _client + .SendRequestAsync( + new JsonRequest + { + Method = HttpMethod.Post, + Path = "plants", + Body = request, + Headers = _headers, + ContentType = "application/json", + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response + .Raw.Content.ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + try + { + var responseData = JsonUtils.Deserialize(responseBody)!; + return new WithRawResponse() + { + Data = responseData, + RawResponse = new RawResponse() + { + StatusCode = response.Raw.StatusCode, + Url = response.Raw.RequestMessage?.RequestUri ?? new Uri("about:blank"), + Headers = ResponseHeaders.FromHttpResponseMessage(response.Raw), + }, + }; + } + catch (JsonException e) + { + throw new SeedApiApiException( + "Failed to deserialize response", + response.StatusCode, + responseBody, + e + ); + } + } + { + var responseBody = await response + .Raw.Content.ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + throw new SeedApiApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + private async Task> CreateTreeAsyncCore( + TreeRecord request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var _headers = await new SeedApi.Core.HeadersBuilder.Builder() + .Add(_client.Options.Headers) + .Add(_client.Options.AdditionalHeaders) + .Add(options?.AdditionalHeaders) + .BuildAsync() + .ConfigureAwait(false); + var response = await _client + .SendRequestAsync( + new JsonRequest + { + Method = HttpMethod.Post, + Path = "trees", + Body = request, + Headers = _headers, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response + .Raw.Content.ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + try + { + var responseData = JsonUtils.Deserialize(responseBody)!; + return new WithRawResponse() + { + Data = responseData, + RawResponse = new RawResponse() + { + StatusCode = response.Raw.StatusCode, + Url = response.Raw.RequestMessage?.RequestUri ?? new Uri("about:blank"), + Headers = ResponseHeaders.FromHttpResponseMessage(response.Raw), + }, + }; + } + catch (JsonException e) + { + throw new SeedApiApiException( + "Failed to deserialize response", + response.StatusCode, + responseBody, + e + ); + } + } + { + var responseBody = await response + .Raw.Content.ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + throw new SeedApiApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + /// /// await client.SearchRuleTypesAsync(new SearchRuleTypesRequest()); /// @@ -430,4 +563,55 @@ public WithRawResponseTask GetOrganizationAsync( GetOrganizationAsyncCore(options, cancellationToken) ); } + + /// + /// Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + /// + /// + /// await client.CreatePlantAsync( + /// new PlantPost + /// { + /// Species = "species", + /// Family = "family", + /// Genus = "genus", + /// CommonName = "commonName", + /// WateringFrequency = PlantPostWateringFrequency.Daily, + /// SunExposure = PlantPostSunExposure.Full, + /// } + /// ); + /// + public WithRawResponseTask CreatePlantAsync( + PlantPost request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + return new WithRawResponseTask( + CreatePlantAsyncCore(request, options, cancellationToken) + ); + } + + /// + /// Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + /// + /// + /// await client.CreateTreeAsync( + /// new TreeRecord + /// { + /// Id = "id", + /// TreeName = "treeName", + /// TreeSpecies = "treeSpecies", + /// } + /// ); + /// + public WithRawResponseTask CreateTreeAsync( + TreeRecord request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + return new WithRawResponseTask( + CreateTreeAsyncCore(request, options, cancellationToken) + ); + } } diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantBase.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantBase.cs new file mode 100644 index 000000000000..0da6a14739de --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantBase.cs @@ -0,0 +1,52 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record PlantBase : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// The botanical species name. + /// + [JsonPropertyName("species")] + public required string Species { get; set; } + + /// + /// The botanical family. + /// + [JsonPropertyName("family")] + public required string Family { get; set; } + + /// + /// The botanical genus. + /// + [JsonPropertyName("genus")] + public required string Genus { get; set; } + + /// + /// The common name of the plant. + /// + [JsonPropertyName("commonName")] + public string? CommonName { get; set; } + + [JsonPropertyName("wateringFrequency")] + public PlantBaseWateringFrequency? WateringFrequency { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantBaseWateringFrequency.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantBaseWateringFrequency.cs new file mode 100644 index 000000000000..21cf02a95749 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantBaseWateringFrequency.cs @@ -0,0 +1,123 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[JsonConverter(typeof(PlantBaseWateringFrequency.PlantBaseWateringFrequencySerializer))] +[Serializable] +public readonly record struct PlantBaseWateringFrequency : IStringEnum +{ + public static readonly PlantBaseWateringFrequency Daily = new(Values.Daily); + + public static readonly PlantBaseWateringFrequency Weekly = new(Values.Weekly); + + public static readonly PlantBaseWateringFrequency Biweekly = new(Values.Biweekly); + + public static readonly PlantBaseWateringFrequency Monthly = new(Values.Monthly); + + public PlantBaseWateringFrequency(string value) + { + Value = value; + } + + /// + /// The string value of the enum. + /// + public string Value { get; } + + /// + /// Create a string enum with the given value. + /// + public static PlantBaseWateringFrequency FromCustom(string value) + { + return new PlantBaseWateringFrequency(value); + } + + public bool Equals(string? other) + { + return Value.Equals(other); + } + + /// + /// Returns the string value of the enum. + /// + public override string ToString() + { + return Value; + } + + public static bool operator ==(PlantBaseWateringFrequency value1, string value2) => + value1.Value.Equals(value2); + + public static bool operator !=(PlantBaseWateringFrequency value1, string value2) => + !value1.Value.Equals(value2); + + public static explicit operator string(PlantBaseWateringFrequency value) => value.Value; + + public static explicit operator PlantBaseWateringFrequency(string value) => new(value); + + internal class PlantBaseWateringFrequencySerializer : JsonConverter + { + public override PlantBaseWateringFrequency Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception( + "The JSON value could not be read as a string." + ); + return new PlantBaseWateringFrequency(stringValue); + } + + public override void Write( + Utf8JsonWriter writer, + PlantBaseWateringFrequency value, + JsonSerializerOptions options + ) + { + writer.WriteStringValue(value.Value); + } + + public override PlantBaseWateringFrequency ReadAsPropertyName( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception( + "The JSON property name could not be read as a string." + ); + return new PlantBaseWateringFrequency(stringValue); + } + + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + PlantBaseWateringFrequency value, + JsonSerializerOptions options + ) + { + writer.WritePropertyName(value.Value); + } + } + + /// + /// Constant strings for enum values + /// + [Serializable] + public static class Values + { + public const string Daily = "daily"; + + public const string Weekly = "weekly"; + + public const string Biweekly = "biweekly"; + + public const string Monthly = "monthly"; + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantPostSunExposure.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantPostSunExposure.cs new file mode 100644 index 000000000000..6bb2024aa2e0 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantPostSunExposure.cs @@ -0,0 +1,119 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[JsonConverter(typeof(PlantPostSunExposure.PlantPostSunExposureSerializer))] +[Serializable] +public readonly record struct PlantPostSunExposure : IStringEnum +{ + public static readonly PlantPostSunExposure Full = new(Values.Full); + + public static readonly PlantPostSunExposure Partial = new(Values.Partial); + + public static readonly PlantPostSunExposure Shade = new(Values.Shade); + + public PlantPostSunExposure(string value) + { + Value = value; + } + + /// + /// The string value of the enum. + /// + public string Value { get; } + + /// + /// Create a string enum with the given value. + /// + public static PlantPostSunExposure FromCustom(string value) + { + return new PlantPostSunExposure(value); + } + + public bool Equals(string? other) + { + return Value.Equals(other); + } + + /// + /// Returns the string value of the enum. + /// + public override string ToString() + { + return Value; + } + + public static bool operator ==(PlantPostSunExposure value1, string value2) => + value1.Value.Equals(value2); + + public static bool operator !=(PlantPostSunExposure value1, string value2) => + !value1.Value.Equals(value2); + + public static explicit operator string(PlantPostSunExposure value) => value.Value; + + public static explicit operator PlantPostSunExposure(string value) => new(value); + + internal class PlantPostSunExposureSerializer : JsonConverter + { + public override PlantPostSunExposure Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception( + "The JSON value could not be read as a string." + ); + return new PlantPostSunExposure(stringValue); + } + + public override void Write( + Utf8JsonWriter writer, + PlantPostSunExposure value, + JsonSerializerOptions options + ) + { + writer.WriteStringValue(value.Value); + } + + public override PlantPostSunExposure ReadAsPropertyName( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception( + "The JSON property name could not be read as a string." + ); + return new PlantPostSunExposure(stringValue); + } + + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + PlantPostSunExposure value, + JsonSerializerOptions options + ) + { + writer.WritePropertyName(value.Value); + } + } + + /// + /// Constant strings for enum values + /// + [Serializable] + public static class Values + { + public const string Full = "full"; + + public const string Partial = "partial"; + + public const string Shade = "shade"; + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantPostWateringFrequency.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantPostWateringFrequency.cs new file mode 100644 index 000000000000..96740b82bb64 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantPostWateringFrequency.cs @@ -0,0 +1,123 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[JsonConverter(typeof(PlantPostWateringFrequency.PlantPostWateringFrequencySerializer))] +[Serializable] +public readonly record struct PlantPostWateringFrequency : IStringEnum +{ + public static readonly PlantPostWateringFrequency Daily = new(Values.Daily); + + public static readonly PlantPostWateringFrequency Weekly = new(Values.Weekly); + + public static readonly PlantPostWateringFrequency Biweekly = new(Values.Biweekly); + + public static readonly PlantPostWateringFrequency Monthly = new(Values.Monthly); + + public PlantPostWateringFrequency(string value) + { + Value = value; + } + + /// + /// The string value of the enum. + /// + public string Value { get; } + + /// + /// Create a string enum with the given value. + /// + public static PlantPostWateringFrequency FromCustom(string value) + { + return new PlantPostWateringFrequency(value); + } + + public bool Equals(string? other) + { + return Value.Equals(other); + } + + /// + /// Returns the string value of the enum. + /// + public override string ToString() + { + return Value; + } + + public static bool operator ==(PlantPostWateringFrequency value1, string value2) => + value1.Value.Equals(value2); + + public static bool operator !=(PlantPostWateringFrequency value1, string value2) => + !value1.Value.Equals(value2); + + public static explicit operator string(PlantPostWateringFrequency value) => value.Value; + + public static explicit operator PlantPostWateringFrequency(string value) => new(value); + + internal class PlantPostWateringFrequencySerializer : JsonConverter + { + public override PlantPostWateringFrequency Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception( + "The JSON value could not be read as a string." + ); + return new PlantPostWateringFrequency(stringValue); + } + + public override void Write( + Utf8JsonWriter writer, + PlantPostWateringFrequency value, + JsonSerializerOptions options + ) + { + writer.WriteStringValue(value.Value); + } + + public override PlantPostWateringFrequency ReadAsPropertyName( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception( + "The JSON property name could not be read as a string." + ); + return new PlantPostWateringFrequency(stringValue); + } + + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + PlantPostWateringFrequency value, + JsonSerializerOptions options + ) + { + writer.WritePropertyName(value.Value); + } + } + + /// + /// Constant strings for enum values + /// + [Serializable] + public static class Values + { + public const string Daily = "daily"; + + public const string Weekly = "weekly"; + + public const string Biweekly = "biweekly"; + + public const string Monthly = "monthly"; + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantStrict.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantStrict.cs new file mode 100644 index 000000000000..19797ad4dcce --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/PlantStrict.cs @@ -0,0 +1,43 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record PlantStrict : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// The botanical species name. + /// + [JsonPropertyName("species")] + public required string Species { get; set; } + + /// + /// The botanical family. + /// + [JsonPropertyName("family")] + public required string Family { get; set; } + + /// + /// The botanical genus. + /// + [JsonPropertyName("genus")] + public required string Genus { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeBase.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeBase.cs new file mode 100644 index 000000000000..61605244b66e --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeBase.cs @@ -0,0 +1,55 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record TreeBase : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// Unique tree identifier. + /// + [JsonPropertyName("id")] + public required string Id { get; set; } + + /// + /// Display name of the tree. + /// + [JsonPropertyName("treeName")] + public string? TreeName { get; set; } + + /// + /// A description of the tree. + /// + [JsonPropertyName("treeDescription")] + public string? TreeDescription { get; set; } + + /// + /// The species of tree. + /// + [JsonPropertyName("treeSpecies")] + public string? TreeSpecies { get; set; } + + /// + /// Height of the tree in feet. + /// + [JsonPropertyName("heightInFeet")] + public double? HeightInFeet { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeDescribable.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeDescribable.cs new file mode 100644 index 000000000000..4c6bd360a758 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeDescribable.cs @@ -0,0 +1,37 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record TreeDescribable : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// Display name of the tree. + /// + [JsonPropertyName("treeName")] + public string? TreeName { get; set; } + + /// + /// A description of the tree. + /// + [JsonPropertyName("treeDescription")] + public string? TreeDescription { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeIdentifiable.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeIdentifiable.cs new file mode 100644 index 000000000000..c8e6ef3e1299 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeIdentifiable.cs @@ -0,0 +1,31 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record TreeIdentifiable : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// Unique tree identifier. + /// + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeRecord.cs b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeRecord.cs new file mode 100644 index 000000000000..df414345abd2 --- /dev/null +++ b/seed/csharp-sdk/allof-inline/src/SeedApi/Types/TreeRecord.cs @@ -0,0 +1,61 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record TreeRecord : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// Unique tree identifier. + /// + [JsonPropertyName("id")] + public required string Id { get; set; } + + /// + /// Display name of the tree. + /// + [JsonPropertyName("treeName")] + public required string TreeName { get; set; } + + /// + /// A description of the tree. + /// + [JsonPropertyName("treeDescription")] + public string? TreeDescription { get; set; } + + /// + /// The species of tree. + /// + [JsonPropertyName("treeSpecies")] + public required string TreeSpecies { get; set; } + + /// + /// Height of the tree in feet. + /// + [JsonPropertyName("heightInFeet")] + public double? HeightInFeet { get; set; } + + /// + /// Date the tree was planted. + /// + [JsonPropertyName("plantedDate")] + public DateOnly? PlantedDate { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof/Snippets/Example10.cs b/seed/csharp-sdk/allof/Snippets/Example10.cs new file mode 100644 index 000000000000..8aa2663c4670 --- /dev/null +++ b/seed/csharp-sdk/allof/Snippets/Example10.cs @@ -0,0 +1,22 @@ +using SeedApi; + +public partial class Examples +{ + public async Task Example10() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.CreatePlantAsync( + new PlantPost { + Species = "species", + Family = "family", + Genus = "genus", + SunExposure = PlantPostSunExposure.Full + } + ); + } + +} diff --git a/seed/csharp-sdk/allof/Snippets/Example11.cs b/seed/csharp-sdk/allof/Snippets/Example11.cs new file mode 100644 index 000000000000..5f159ad4d991 --- /dev/null +++ b/seed/csharp-sdk/allof/Snippets/Example11.cs @@ -0,0 +1,26 @@ +using SeedApi; + +public partial class Examples +{ + public async Task Example11() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.CreatePlantAsync( + new PlantPost { + CommonName = "commonName", + WateringFrequency = PlantBaseWateringFrequency.Daily, + Species = "species", + Family = "family", + Genus = "genus", + SunExposure = PlantPostSunExposure.Full, + PlantedAt = DateOnly.Parse("2023-01-15"), + SoilType = "soilType" + } + ); + } + +} diff --git a/seed/csharp-sdk/allof/Snippets/Example12.cs b/seed/csharp-sdk/allof/Snippets/Example12.cs new file mode 100644 index 000000000000..f100ab67388b --- /dev/null +++ b/seed/csharp-sdk/allof/Snippets/Example12.cs @@ -0,0 +1,19 @@ +using SeedApi; + +public partial class Examples +{ + public async Task Example12() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.CreateTreeAsync( + new TreeRecord { + Id = "id" + } + ); + } + +} diff --git a/seed/csharp-sdk/allof/Snippets/Example13.cs b/seed/csharp-sdk/allof/Snippets/Example13.cs new file mode 100644 index 000000000000..dfa4b032fd25 --- /dev/null +++ b/seed/csharp-sdk/allof/Snippets/Example13.cs @@ -0,0 +1,24 @@ +using SeedApi; + +public partial class Examples +{ + public async Task Example13() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.CreateTreeAsync( + new TreeRecord { + TreeSpecies = "treeSpecies", + HeightInFeet = 1.1, + Id = "id", + TreeName = "treeName", + TreeDescription = "treeDescription", + PlantedDate = DateOnly.Parse("2023-01-15") + } + ); + } + +} diff --git a/seed/csharp-sdk/allof/reference.md b/seed/csharp-sdk/allof/reference.md index eab7f3a438d2..c11b706aab3b 100644 --- a/seed/csharp-sdk/allof/reference.md +++ b/seed/csharp-sdk/allof/reference.md @@ -160,3 +160,119 @@ await client.GetOrganizationAsync(); +
client.CreatePlantAsync(PlantPost { ... }) -> WithRawResponseTask<PlantStrict> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.CreatePlantAsync( + new PlantPost + { + Species = "species", + Family = "family", + Genus = "genus", + SunExposure = PlantPostSunExposure.Full, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `PlantPost` + +
+
+
+
+ + +
+
+
+ +
client.CreateTreeAsync(TreeRecord { ... }) -> WithRawResponseTask<TreeRecord> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.CreateTreeAsync(new TreeRecord { Id = "id" }); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TreeRecord` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/csharp-sdk/allof/snippet.json b/seed/csharp-sdk/allof/snippet.json index 56a6d70194b2..28c47cbd8165 100644 --- a/seed/csharp-sdk/allof/snippet.json +++ b/seed/csharp-sdk/allof/snippet.json @@ -60,6 +60,30 @@ "type": "csharp", "client": "using SeedApi;\n\nvar client = new SeedApiClient();\nawait client.GetOrganizationAsync();\n" } + }, + { + "example_identifier": null, + "id": { + "path": "/plants", + "method": "POST", + "identifier_override": "endpoint_.createPlant" + }, + "snippet": { + "type": "csharp", + "client": "using SeedApi;\n\nvar client = new SeedApiClient();\nawait client.CreatePlantAsync(\n new PlantPost\n {\n Species = \"species\",\n Family = \"family\",\n Genus = \"genus\",\n SunExposure = PlantPostSunExposure.Full,\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/trees", + "method": "POST", + "identifier_override": "endpoint_.createTree" + }, + "snippet": { + "type": "csharp", + "client": "using SeedApi;\n\nvar client = new SeedApiClient();\nawait client.CreateTreeAsync(new TreeRecord { Id = \"id\" });\n" + } } ] } \ No newline at end of file diff --git a/seed/csharp-sdk/allof/src/SeedApi.Test/Unit/MockServer/CreatePlantTest.cs b/seed/csharp-sdk/allof/src/SeedApi.Test/Unit/MockServer/CreatePlantTest.cs new file mode 100644 index 000000000000..7b1a9101fd47 --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi.Test/Unit/MockServer/CreatePlantTest.cs @@ -0,0 +1,114 @@ +using NUnit.Framework; +using SeedApi; +using SeedApi.Test.Utils; + +namespace SeedApi.Test.Unit.MockServer; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class CreatePlantTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest_1() + { + const string requestJson = """ + { + "sunExposure": "full", + "plantedAt": "2023-01-15", + "soilType": "soilType", + "commonName": "commonName", + "wateringFrequency": "daily", + "species": "species", + "family": "family", + "genus": "genus" + } + """; + + const string mockResponse = """ + { + "species": "species", + "family": "family", + "genus": "genus" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/plants") + .WithHeader("Content-Type", "application/json") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.CreatePlantAsync( + new PlantPost + { + SunExposure = PlantPostSunExposure.Full, + PlantedAt = new DateOnly(2023, 1, 15), + SoilType = "soilType", + CommonName = "commonName", + WateringFrequency = PlantBaseWateringFrequency.Daily, + Species = "species", + Family = "family", + Genus = "genus", + } + ); + JsonAssert.AreEqual(response, mockResponse); + } + + [NUnit.Framework.Test] + public async Task MockServerTest_2() + { + const string requestJson = """ + { + "species": "species", + "family": "family", + "genus": "genus", + "sunExposure": "full" + } + """; + + const string mockResponse = """ + { + "species": "species", + "family": "family", + "genus": "genus" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/plants") + .WithHeader("Content-Type", "application/json") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.CreatePlantAsync( + new PlantPost + { + Species = "species", + Family = "family", + Genus = "genus", + SunExposure = PlantPostSunExposure.Full, + } + ); + JsonAssert.AreEqual(response, mockResponse); + } +} diff --git a/seed/csharp-sdk/allof/src/SeedApi.Test/Unit/MockServer/CreateTreeTest.cs b/seed/csharp-sdk/allof/src/SeedApi.Test/Unit/MockServer/CreateTreeTest.cs new file mode 100644 index 000000000000..55cd7b79ee27 --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi.Test/Unit/MockServer/CreateTreeTest.cs @@ -0,0 +1,103 @@ +using NUnit.Framework; +using SeedApi; +using SeedApi.Test.Utils; + +namespace SeedApi.Test.Unit.MockServer; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class CreateTreeTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest_1() + { + const string requestJson = """ + { + "plantedDate": "2023-01-15", + "treeSpecies": "treeSpecies", + "heightInFeet": 1.1, + "id": "id", + "treeName": "treeName", + "treeDescription": "treeDescription" + } + """; + + const string mockResponse = """ + { + "plantedDate": "2023-01-15", + "treeSpecies": "treeSpecies", + "heightInFeet": 1.1, + "id": "id", + "treeName": "treeName", + "treeDescription": "treeDescription" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/trees") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.CreateTreeAsync( + new TreeRecord + { + PlantedDate = new DateOnly(2023, 1, 15), + TreeSpecies = "treeSpecies", + HeightInFeet = 1.1, + Id = "id", + TreeName = "treeName", + TreeDescription = "treeDescription", + } + ); + JsonAssert.AreEqual(response, mockResponse); + } + + [NUnit.Framework.Test] + public async Task MockServerTest_2() + { + const string requestJson = """ + { + "id": "id" + } + """; + + const string mockResponse = """ + { + "treeName": "treeName", + "treeDescription": "treeDescription", + "id": "id", + "treeSpecies": "treeSpecies", + "heightInFeet": 1.1, + "plantedDate": "2023-01-15" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/trees") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.CreateTreeAsync(new TreeRecord { Id = "id" }); + JsonAssert.AreEqual(response, mockResponse); + } +} diff --git a/seed/csharp-sdk/allof/src/SeedApi/ISeedApiClient.cs b/seed/csharp-sdk/allof/src/SeedApi/ISeedApiClient.cs index 4b1eb3e650c8..2d0b14cff320 100644 --- a/seed/csharp-sdk/allof/src/SeedApi/ISeedApiClient.cs +++ b/seed/csharp-sdk/allof/src/SeedApi/ISeedApiClient.cs @@ -28,4 +28,22 @@ WithRawResponseTask GetOrganizationAsync( RequestOptions? options = null, CancellationToken cancellationToken = default ); + + /// + /// Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + /// + WithRawResponseTask CreatePlantAsync( + PlantPost request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + /// + WithRawResponseTask CreateTreeAsync( + TreeRecord request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); } diff --git a/seed/csharp-sdk/allof/src/SeedApi/Requests/PlantPost.cs b/seed/csharp-sdk/allof/src/SeedApi/Requests/PlantPost.cs new file mode 100644 index 000000000000..a514c3621f1a --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi/Requests/PlantPost.cs @@ -0,0 +1,59 @@ +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record PlantPost +{ + /// + /// Required sun exposure level. + /// + [JsonPropertyName("sunExposure")] + public required PlantPostSunExposure SunExposure { get; set; } + + /// + /// Date the plant was planted. + /// + [JsonPropertyName("plantedAt")] + public DateOnly? PlantedAt { get; set; } + + /// + /// Preferred soil type. + /// + [JsonPropertyName("soilType")] + public string? SoilType { get; set; } + + /// + /// The common name of the plant. + /// + [JsonPropertyName("commonName")] + public string? CommonName { get; set; } + + [JsonPropertyName("wateringFrequency")] + public PlantBaseWateringFrequency? WateringFrequency { get; set; } + + /// + /// The botanical species name. + /// + [JsonPropertyName("species")] + public required string Species { get; set; } + + /// + /// The botanical family. + /// + [JsonPropertyName("family")] + public required string Family { get; set; } + + /// + /// The botanical genus. + /// + [JsonPropertyName("genus")] + public required string Genus { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof/src/SeedApi/SeedApiClient.cs b/seed/csharp-sdk/allof/src/SeedApi/SeedApiClient.cs index a966af08225e..ac2671fc0373 100644 --- a/seed/csharp-sdk/allof/src/SeedApi/SeedApiClient.cs +++ b/seed/csharp-sdk/allof/src/SeedApi/SeedApiClient.cs @@ -358,6 +358,139 @@ private async Task> GetOrganizationAsyncCore( } } + private async Task> CreatePlantAsyncCore( + PlantPost request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var _headers = await new SeedApi.Core.HeadersBuilder.Builder() + .Add(_client.Options.Headers) + .Add(_client.Options.AdditionalHeaders) + .Add(options?.AdditionalHeaders) + .BuildAsync() + .ConfigureAwait(false); + var response = await _client + .SendRequestAsync( + new JsonRequest + { + Method = HttpMethod.Post, + Path = "plants", + Body = request, + Headers = _headers, + ContentType = "application/json", + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response + .Raw.Content.ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + try + { + var responseData = JsonUtils.Deserialize(responseBody)!; + return new WithRawResponse() + { + Data = responseData, + RawResponse = new RawResponse() + { + StatusCode = response.Raw.StatusCode, + Url = response.Raw.RequestMessage?.RequestUri ?? new Uri("about:blank"), + Headers = ResponseHeaders.FromHttpResponseMessage(response.Raw), + }, + }; + } + catch (JsonException e) + { + throw new SeedApiApiException( + "Failed to deserialize response", + response.StatusCode, + responseBody, + e + ); + } + } + { + var responseBody = await response + .Raw.Content.ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + throw new SeedApiApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + private async Task> CreateTreeAsyncCore( + TreeRecord request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var _headers = await new SeedApi.Core.HeadersBuilder.Builder() + .Add(_client.Options.Headers) + .Add(_client.Options.AdditionalHeaders) + .Add(options?.AdditionalHeaders) + .BuildAsync() + .ConfigureAwait(false); + var response = await _client + .SendRequestAsync( + new JsonRequest + { + Method = HttpMethod.Post, + Path = "trees", + Body = request, + Headers = _headers, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response + .Raw.Content.ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + try + { + var responseData = JsonUtils.Deserialize(responseBody)!; + return new WithRawResponse() + { + Data = responseData, + RawResponse = new RawResponse() + { + StatusCode = response.Raw.StatusCode, + Url = response.Raw.RequestMessage?.RequestUri ?? new Uri("about:blank"), + Headers = ResponseHeaders.FromHttpResponseMessage(response.Raw), + }, + }; + } + catch (JsonException e) + { + throw new SeedApiApiException( + "Failed to deserialize response", + response.StatusCode, + responseBody, + e + ); + } + } + { + var responseBody = await response + .Raw.Content.ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + throw new SeedApiApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + /// /// await client.SearchRuleTypesAsync(new SearchRuleTypesRequest()); /// @@ -430,4 +563,46 @@ public WithRawResponseTask GetOrganizationAsync( GetOrganizationAsyncCore(options, cancellationToken) ); } + + /// + /// Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + /// + /// + /// await client.CreatePlantAsync( + /// new PlantPost + /// { + /// Species = "species", + /// Family = "family", + /// Genus = "genus", + /// SunExposure = PlantPostSunExposure.Full, + /// } + /// ); + /// + public WithRawResponseTask CreatePlantAsync( + PlantPost request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + return new WithRawResponseTask( + CreatePlantAsyncCore(request, options, cancellationToken) + ); + } + + /// + /// Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + /// + /// + /// await client.CreateTreeAsync(new TreeRecord { Id = "id" }); + /// + public WithRawResponseTask CreateTreeAsync( + TreeRecord request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + return new WithRawResponseTask( + CreateTreeAsyncCore(request, options, cancellationToken) + ); + } } diff --git a/seed/csharp-sdk/allof/src/SeedApi/Types/PlantBase.cs b/seed/csharp-sdk/allof/src/SeedApi/Types/PlantBase.cs new file mode 100644 index 000000000000..bedf2c7f8eba --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi/Types/PlantBase.cs @@ -0,0 +1,52 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record PlantBase : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// The common name of the plant. + /// + [JsonPropertyName("commonName")] + public string? CommonName { get; set; } + + [JsonPropertyName("wateringFrequency")] + public PlantBaseWateringFrequency? WateringFrequency { get; set; } + + /// + /// The botanical species name. + /// + [JsonPropertyName("species")] + public required string Species { get; set; } + + /// + /// The botanical family. + /// + [JsonPropertyName("family")] + public required string Family { get; set; } + + /// + /// The botanical genus. + /// + [JsonPropertyName("genus")] + public required string Genus { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof/src/SeedApi/Types/PlantBaseWateringFrequency.cs b/seed/csharp-sdk/allof/src/SeedApi/Types/PlantBaseWateringFrequency.cs new file mode 100644 index 000000000000..21cf02a95749 --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi/Types/PlantBaseWateringFrequency.cs @@ -0,0 +1,123 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[JsonConverter(typeof(PlantBaseWateringFrequency.PlantBaseWateringFrequencySerializer))] +[Serializable] +public readonly record struct PlantBaseWateringFrequency : IStringEnum +{ + public static readonly PlantBaseWateringFrequency Daily = new(Values.Daily); + + public static readonly PlantBaseWateringFrequency Weekly = new(Values.Weekly); + + public static readonly PlantBaseWateringFrequency Biweekly = new(Values.Biweekly); + + public static readonly PlantBaseWateringFrequency Monthly = new(Values.Monthly); + + public PlantBaseWateringFrequency(string value) + { + Value = value; + } + + /// + /// The string value of the enum. + /// + public string Value { get; } + + /// + /// Create a string enum with the given value. + /// + public static PlantBaseWateringFrequency FromCustom(string value) + { + return new PlantBaseWateringFrequency(value); + } + + public bool Equals(string? other) + { + return Value.Equals(other); + } + + /// + /// Returns the string value of the enum. + /// + public override string ToString() + { + return Value; + } + + public static bool operator ==(PlantBaseWateringFrequency value1, string value2) => + value1.Value.Equals(value2); + + public static bool operator !=(PlantBaseWateringFrequency value1, string value2) => + !value1.Value.Equals(value2); + + public static explicit operator string(PlantBaseWateringFrequency value) => value.Value; + + public static explicit operator PlantBaseWateringFrequency(string value) => new(value); + + internal class PlantBaseWateringFrequencySerializer : JsonConverter + { + public override PlantBaseWateringFrequency Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception( + "The JSON value could not be read as a string." + ); + return new PlantBaseWateringFrequency(stringValue); + } + + public override void Write( + Utf8JsonWriter writer, + PlantBaseWateringFrequency value, + JsonSerializerOptions options + ) + { + writer.WriteStringValue(value.Value); + } + + public override PlantBaseWateringFrequency ReadAsPropertyName( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception( + "The JSON property name could not be read as a string." + ); + return new PlantBaseWateringFrequency(stringValue); + } + + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + PlantBaseWateringFrequency value, + JsonSerializerOptions options + ) + { + writer.WritePropertyName(value.Value); + } + } + + /// + /// Constant strings for enum values + /// + [Serializable] + public static class Values + { + public const string Daily = "daily"; + + public const string Weekly = "weekly"; + + public const string Biweekly = "biweekly"; + + public const string Monthly = "monthly"; + } +} diff --git a/seed/csharp-sdk/allof/src/SeedApi/Types/PlantPostSunExposure.cs b/seed/csharp-sdk/allof/src/SeedApi/Types/PlantPostSunExposure.cs new file mode 100644 index 000000000000..6bb2024aa2e0 --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi/Types/PlantPostSunExposure.cs @@ -0,0 +1,119 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[JsonConverter(typeof(PlantPostSunExposure.PlantPostSunExposureSerializer))] +[Serializable] +public readonly record struct PlantPostSunExposure : IStringEnum +{ + public static readonly PlantPostSunExposure Full = new(Values.Full); + + public static readonly PlantPostSunExposure Partial = new(Values.Partial); + + public static readonly PlantPostSunExposure Shade = new(Values.Shade); + + public PlantPostSunExposure(string value) + { + Value = value; + } + + /// + /// The string value of the enum. + /// + public string Value { get; } + + /// + /// Create a string enum with the given value. + /// + public static PlantPostSunExposure FromCustom(string value) + { + return new PlantPostSunExposure(value); + } + + public bool Equals(string? other) + { + return Value.Equals(other); + } + + /// + /// Returns the string value of the enum. + /// + public override string ToString() + { + return Value; + } + + public static bool operator ==(PlantPostSunExposure value1, string value2) => + value1.Value.Equals(value2); + + public static bool operator !=(PlantPostSunExposure value1, string value2) => + !value1.Value.Equals(value2); + + public static explicit operator string(PlantPostSunExposure value) => value.Value; + + public static explicit operator PlantPostSunExposure(string value) => new(value); + + internal class PlantPostSunExposureSerializer : JsonConverter + { + public override PlantPostSunExposure Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception( + "The JSON value could not be read as a string." + ); + return new PlantPostSunExposure(stringValue); + } + + public override void Write( + Utf8JsonWriter writer, + PlantPostSunExposure value, + JsonSerializerOptions options + ) + { + writer.WriteStringValue(value.Value); + } + + public override PlantPostSunExposure ReadAsPropertyName( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception( + "The JSON property name could not be read as a string." + ); + return new PlantPostSunExposure(stringValue); + } + + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + PlantPostSunExposure value, + JsonSerializerOptions options + ) + { + writer.WritePropertyName(value.Value); + } + } + + /// + /// Constant strings for enum values + /// + [Serializable] + public static class Values + { + public const string Full = "full"; + + public const string Partial = "partial"; + + public const string Shade = "shade"; + } +} diff --git a/seed/csharp-sdk/allof/src/SeedApi/Types/PlantStrict.cs b/seed/csharp-sdk/allof/src/SeedApi/Types/PlantStrict.cs new file mode 100644 index 000000000000..19797ad4dcce --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi/Types/PlantStrict.cs @@ -0,0 +1,43 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record PlantStrict : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// The botanical species name. + /// + [JsonPropertyName("species")] + public required string Species { get; set; } + + /// + /// The botanical family. + /// + [JsonPropertyName("family")] + public required string Family { get; set; } + + /// + /// The botanical genus. + /// + [JsonPropertyName("genus")] + public required string Genus { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof/src/SeedApi/Types/TreeBase.cs b/seed/csharp-sdk/allof/src/SeedApi/Types/TreeBase.cs new file mode 100644 index 000000000000..78a08eb67e73 --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi/Types/TreeBase.cs @@ -0,0 +1,55 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record TreeBase : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// The species of tree. + /// + [JsonPropertyName("treeSpecies")] + public string? TreeSpecies { get; set; } + + /// + /// Height of the tree in feet. + /// + [JsonPropertyName("heightInFeet")] + public double? HeightInFeet { get; set; } + + /// + /// Unique tree identifier. + /// + [JsonPropertyName("id")] + public required string Id { get; set; } + + /// + /// Display name of the tree. + /// + [JsonPropertyName("treeName")] + public string? TreeName { get; set; } + + /// + /// A description of the tree. + /// + [JsonPropertyName("treeDescription")] + public string? TreeDescription { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof/src/SeedApi/Types/TreeDescribable.cs b/seed/csharp-sdk/allof/src/SeedApi/Types/TreeDescribable.cs new file mode 100644 index 000000000000..4c6bd360a758 --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi/Types/TreeDescribable.cs @@ -0,0 +1,37 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record TreeDescribable : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// Display name of the tree. + /// + [JsonPropertyName("treeName")] + public string? TreeName { get; set; } + + /// + /// A description of the tree. + /// + [JsonPropertyName("treeDescription")] + public string? TreeDescription { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof/src/SeedApi/Types/TreeIdentifiable.cs b/seed/csharp-sdk/allof/src/SeedApi/Types/TreeIdentifiable.cs new file mode 100644 index 000000000000..c8e6ef3e1299 --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi/Types/TreeIdentifiable.cs @@ -0,0 +1,31 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record TreeIdentifiable : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// Unique tree identifier. + /// + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/allof/src/SeedApi/Types/TreeRecord.cs b/seed/csharp-sdk/allof/src/SeedApi/Types/TreeRecord.cs new file mode 100644 index 000000000000..2eab67c96303 --- /dev/null +++ b/seed/csharp-sdk/allof/src/SeedApi/Types/TreeRecord.cs @@ -0,0 +1,61 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record TreeRecord : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + /// + /// Date the tree was planted. + /// + [JsonPropertyName("plantedDate")] + public DateOnly? PlantedDate { get; set; } + + /// + /// The species of tree. + /// + [JsonPropertyName("treeSpecies")] + public string? TreeSpecies { get; set; } + + /// + /// Height of the tree in feet. + /// + [JsonPropertyName("heightInFeet")] + public double? HeightInFeet { get; set; } + + /// + /// Unique tree identifier. + /// + [JsonPropertyName("id")] + public required string Id { get; set; } + + /// + /// Display name of the tree. + /// + [JsonPropertyName("treeName")] + public string? TreeName { get; set; } + + /// + /// A description of the tree. + /// + [JsonPropertyName("treeDescription")] + public string? TreeDescription { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/go-sdk/allof-inline/client/client.go b/seed/go-sdk/allof-inline/client/client.go index aa5339e4d271..2e3d78b48e9d 100644 --- a/seed/go-sdk/allof-inline/client/client.go +++ b/seed/go-sdk/allof-inline/client/client.go @@ -108,3 +108,37 @@ func (c *Client) GetOrganization( } return response.Body, nil } + +// Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +func (c *Client) CreatePlant( + ctx context.Context, + request *fern.PlantPost, + opts ...option.RequestOption, +) (*fern.PlantStrict, error) { + response, err := c.WithRawResponse.CreatePlant( + ctx, + request, + opts..., + ) + if err != nil { + return nil, err + } + return response.Body, nil +} + +// Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +func (c *Client) CreateTree( + ctx context.Context, + request *fern.TreeRecord, + opts ...option.RequestOption, +) (*fern.TreeRecord, error) { + response, err := c.WithRawResponse.CreateTree( + ctx, + request, + opts..., + ) + if err != nil { + return nil, err + } + return response.Body, nil +} diff --git a/seed/go-sdk/allof-inline/client/raw_client.go b/seed/go-sdk/allof-inline/client/raw_client.go index 0993be2c3928..c461cf4c85d8 100644 --- a/seed/go-sdk/allof-inline/client/raw_client.go +++ b/seed/go-sdk/allof-inline/client/raw_client.go @@ -242,3 +242,88 @@ func (r *RawClient) GetOrganization( Body: response, }, nil } + +func (r *RawClient) CreatePlant( + ctx context.Context, + request *fern.PlantPost, + opts ...option.RequestOption, +) (*core.Response[*fern.PlantStrict], error) { + options := core.NewRequestOptions(opts...) + baseURL := internal.ResolveBaseURL( + options.BaseURL, + r.baseURL, + "https://api.example.com", + ) + endpointURL := baseURL + "/plants" + headers := internal.MergeHeaders( + r.options.ToHeader(), + options.ToHeader(), + ) + headers.Add("Content-Type", "application/json") + var response *fern.PlantStrict + raw, err := r.caller.Call( + ctx, + &internal.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + Headers: headers, + MaxAttempts: options.MaxAttempts, + DisableRetries: options.DisableRetries, + BodyProperties: options.BodyProperties, + QueryParameters: options.QueryParameters, + Client: options.HTTPClient, + Request: request, + Response: &response, + }, + ) + if err != nil { + return nil, err + } + return &core.Response[*fern.PlantStrict]{ + StatusCode: raw.StatusCode, + Header: raw.Header, + Body: response, + }, nil +} + +func (r *RawClient) CreateTree( + ctx context.Context, + request *fern.TreeRecord, + opts ...option.RequestOption, +) (*core.Response[*fern.TreeRecord], error) { + options := core.NewRequestOptions(opts...) + baseURL := internal.ResolveBaseURL( + options.BaseURL, + r.baseURL, + "https://api.example.com", + ) + endpointURL := baseURL + "/trees" + headers := internal.MergeHeaders( + r.options.ToHeader(), + options.ToHeader(), + ) + var response *fern.TreeRecord + raw, err := r.caller.Call( + ctx, + &internal.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + Headers: headers, + MaxAttempts: options.MaxAttempts, + DisableRetries: options.DisableRetries, + BodyProperties: options.BodyProperties, + QueryParameters: options.QueryParameters, + Client: options.HTTPClient, + Request: request, + Response: &response, + }, + ) + if err != nil { + return nil, err + } + return &core.Response[*fern.TreeRecord]{ + StatusCode: raw.StatusCode, + Header: raw.Header, + Body: response, + }, nil +} diff --git a/seed/go-sdk/allof-inline/dynamic-snippets/example10/snippet.go b/seed/go-sdk/allof-inline/dynamic-snippets/example10/snippet.go new file mode 100644 index 000000000000..bac0653a047d --- /dev/null +++ b/seed/go-sdk/allof-inline/dynamic-snippets/example10/snippet.go @@ -0,0 +1,29 @@ +package example + +import ( + context "context" + + fern "github.com/allof-inline/fern" + client "github.com/allof-inline/fern/client" + option "github.com/allof-inline/fern/option" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + ) + request := &fern.PlantPost{ + Species: "species", + Family: "family", + Genus: "genus", + CommonName: "commonName", + WateringFrequency: fern.PlantPostWateringFrequencyDaily, + SunExposure: fern.PlantPostSunExposureFull, + } + client.CreatePlant( + context.TODO(), + request, + ) +} diff --git a/seed/go-sdk/allof-inline/dynamic-snippets/example11/snippet.go b/seed/go-sdk/allof-inline/dynamic-snippets/example11/snippet.go new file mode 100644 index 000000000000..ef6921931bca --- /dev/null +++ b/seed/go-sdk/allof-inline/dynamic-snippets/example11/snippet.go @@ -0,0 +1,37 @@ +package example + +import ( + context "context" + + fern "github.com/allof-inline/fern" + client "github.com/allof-inline/fern/client" + option "github.com/allof-inline/fern/option" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + ) + request := &fern.PlantPost{ + Species: "species", + Family: "family", + Genus: "genus", + CommonName: "commonName", + WateringFrequency: fern.PlantPostWateringFrequencyDaily, + SunExposure: fern.PlantPostSunExposureFull, + PlantedAt: fern.Time( + fern.MustParseDate( + "2023-01-15", + ), + ), + SoilType: fern.String( + "soilType", + ), + } + client.CreatePlant( + context.TODO(), + request, + ) +} diff --git a/seed/go-sdk/allof-inline/dynamic-snippets/example12/snippet.go b/seed/go-sdk/allof-inline/dynamic-snippets/example12/snippet.go new file mode 100644 index 000000000000..fad861701a72 --- /dev/null +++ b/seed/go-sdk/allof-inline/dynamic-snippets/example12/snippet.go @@ -0,0 +1,26 @@ +package example + +import ( + context "context" + + fern "github.com/allof-inline/fern" + client "github.com/allof-inline/fern/client" + option "github.com/allof-inline/fern/option" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + ) + request := &fern.TreeRecord{ + ID: "id", + TreeName: "treeName", + TreeSpecies: "treeSpecies", + } + client.CreateTree( + context.TODO(), + request, + ) +} diff --git a/seed/go-sdk/allof-inline/dynamic-snippets/example13/snippet.go b/seed/go-sdk/allof-inline/dynamic-snippets/example13/snippet.go new file mode 100644 index 000000000000..4ac4abda382f --- /dev/null +++ b/seed/go-sdk/allof-inline/dynamic-snippets/example13/snippet.go @@ -0,0 +1,37 @@ +package example + +import ( + context "context" + + fern "github.com/allof-inline/fern" + client "github.com/allof-inline/fern/client" + option "github.com/allof-inline/fern/option" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + ) + request := &fern.TreeRecord{ + ID: "id", + TreeName: "treeName", + TreeDescription: fern.String( + "treeDescription", + ), + TreeSpecies: "treeSpecies", + HeightInFeet: fern.Float64( + 1.1, + ), + PlantedDate: fern.Time( + fern.MustParseDate( + "2023-01-15", + ), + ), + } + client.CreateTree( + context.TODO(), + request, + ) +} diff --git a/seed/go-sdk/allof-inline/reference.md b/seed/go-sdk/allof-inline/reference.md index ef635f89f996..4fbfdfce680d 100644 --- a/seed/go-sdk/allof-inline/reference.md +++ b/seed/go-sdk/allof-inline/reference.md @@ -184,3 +184,188 @@ client.GetOrganization( +
client.CreatePlant(request) -> *fern.PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```go +request := &fern.PlantPost{ + Species: "species", + Family: "family", + Genus: "genus", + CommonName: "commonName", + WateringFrequency: fern.PlantPostWateringFrequencyDaily, + SunExposure: fern.PlantPostSunExposureFull, + } +client.CreatePlant( + context.TODO(), + request, + ) +} +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**species:** `string` — The botanical species name. + +
+
+ +
+
+ +**family:** `string` — The botanical family. + +
+
+ +
+
+ +**genus:** `string` — The botanical genus. + +
+
+ +
+
+ +**commonName:** `string` — The common name of the plant. + +
+
+ +
+
+ +**wateringFrequency:** `*fern.PlantPostWateringFrequency` + +
+
+ +
+
+ +**sunExposure:** `*fern.PlantPostSunExposure` — Required sun exposure level. + +
+
+ +
+
+ +**plantedAt:** `*time.Time` — Date the plant was planted. + +
+
+ +
+
+ +**soilType:** `*string` — Preferred soil type. + +
+
+
+
+ + +
+
+
+ +
client.CreateTree(request) -> *fern.TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```go +request := &fern.TreeRecord{ + ID: "id", + TreeName: "treeName", + TreeSpecies: "treeSpecies", + } +client.CreateTree( + context.TODO(), + request, + ) +} +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `*fern.TreeRecord` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/go-sdk/allof-inline/snippet.json b/seed/go-sdk/allof-inline/snippet.json index ddd804a6390e..d461686dda39 100644 --- a/seed/go-sdk/allof-inline/snippet.json +++ b/seed/go-sdk/allof-inline/snippet.json @@ -22,6 +22,17 @@ "client": "import (\n\tcontext \"context\"\n\tfernclient \"github.com/allof-inline/fern/client\"\n)\n\nclient := fernclient.NewClient()\nresponse, err := client.GetOrganization(\n\tcontext.TODO(),\n)\n" } }, + { + "id": { + "path": "/plants", + "method": "POST", + "identifier_override": "endpoint_.createPlant" + }, + "snippet": { + "type": "go", + "client": "import (\n\tcontext \"context\"\n\tfern \"github.com/allof-inline/fern\"\n\tfernclient \"github.com/allof-inline/fern/client\"\n)\n\nclient := fernclient.NewClient()\nresponse, err := client.CreatePlant(\n\tcontext.TODO(),\n\t\u0026fern.PlantPost{\n\t\tSpecies: \"species\",\n\t\tFamily: \"family\",\n\t\tGenus: \"genus\",\n\t\tCommonName: \"commonName\",\n\t\tWateringFrequency: fern.PlantPostWateringFrequencyDaily,\n\t\tSunExposure: fern.PlantPostSunExposureFull,\n\t},\n)\n" + } + }, { "id": { "path": "/rule-types", @@ -44,6 +55,17 @@ "client": "import (\n\tcontext \"context\"\n\tfern \"github.com/allof-inline/fern\"\n\tfernclient \"github.com/allof-inline/fern/client\"\n)\n\nclient := fernclient.NewClient()\nresponse, err := client.CreateRule(\n\tcontext.TODO(),\n\t\u0026fern.RuleCreateRequest{\n\t\tName: \"name\",\n\t\tExecutionContext: fern.RuleCreateRequestExecutionContextProd,\n\t},\n)\n" } }, + { + "id": { + "path": "/trees", + "method": "POST", + "identifier_override": "endpoint_.createTree" + }, + "snippet": { + "type": "go", + "client": "import (\n\tcontext \"context\"\n\tfern \"github.com/allof-inline/fern\"\n\tfernclient \"github.com/allof-inline/fern/client\"\n)\n\nclient := fernclient.NewClient()\nresponse, err := client.CreateTree(\n\tcontext.TODO(),\n\t\u0026fern.TreeRecord{\n\t\tID: \"id\",\n\t\tTreeName: \"treeName\",\n\t\tTreeSpecies: \"treeSpecies\",\n\t},\n)\n" + } + }, { "id": { "path": "/users", diff --git a/seed/go-sdk/allof-inline/types.go b/seed/go-sdk/allof-inline/types.go index 6dac282191a7..5923668312c5 100644 --- a/seed/go-sdk/allof-inline/types.go +++ b/seed/go-sdk/allof-inline/types.go @@ -10,6 +10,124 @@ import ( time "time" ) +var ( + plantPostFieldSpecies = big.NewInt(1 << 0) + plantPostFieldFamily = big.NewInt(1 << 1) + plantPostFieldGenus = big.NewInt(1 << 2) + plantPostFieldCommonName = big.NewInt(1 << 3) + plantPostFieldWateringFrequency = big.NewInt(1 << 4) + plantPostFieldSunExposure = big.NewInt(1 << 5) + plantPostFieldPlantedAt = big.NewInt(1 << 6) + plantPostFieldSoilType = big.NewInt(1 << 7) +) + +type PlantPost struct { + // The botanical species name. + Species string `json:"species" url:"-"` + // The botanical family. + Family string `json:"family" url:"-"` + // The botanical genus. + Genus string `json:"genus" url:"-"` + // The common name of the plant. + CommonName string `json:"commonName" url:"-"` + WateringFrequency PlantPostWateringFrequency `json:"wateringFrequency" url:"-"` + // Required sun exposure level. + SunExposure PlantPostSunExposure `json:"sunExposure" url:"-"` + // Date the plant was planted. + PlantedAt *time.Time `json:"plantedAt,omitempty" url:"-" format:"date"` + // Preferred soil type. + SoilType *string `json:"soilType,omitempty" url:"-"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` +} + +func (p *PlantPost) require(field *big.Int) { + if p.explicitFields == nil { + p.explicitFields = big.NewInt(0) + } + p.explicitFields.Or(p.explicitFields, field) +} + +// SetSpecies sets the Species field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetSpecies(species string) { + p.Species = species + p.require(plantPostFieldSpecies) +} + +// SetFamily sets the Family field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetFamily(family string) { + p.Family = family + p.require(plantPostFieldFamily) +} + +// SetGenus sets the Genus field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetGenus(genus string) { + p.Genus = genus + p.require(plantPostFieldGenus) +} + +// SetCommonName sets the CommonName field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetCommonName(commonName string) { + p.CommonName = commonName + p.require(plantPostFieldCommonName) +} + +// SetWateringFrequency sets the WateringFrequency field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetWateringFrequency(wateringFrequency PlantPostWateringFrequency) { + p.WateringFrequency = wateringFrequency + p.require(plantPostFieldWateringFrequency) +} + +// SetSunExposure sets the SunExposure field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetSunExposure(sunExposure PlantPostSunExposure) { + p.SunExposure = sunExposure + p.require(plantPostFieldSunExposure) +} + +// SetPlantedAt sets the PlantedAt field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetPlantedAt(plantedAt *time.Time) { + p.PlantedAt = plantedAt + p.require(plantPostFieldPlantedAt) +} + +// SetSoilType sets the SoilType field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantPost) SetSoilType(soilType *string) { + p.SoilType = soilType + p.require(plantPostFieldSoilType) +} + +func (p *PlantPost) UnmarshalJSON(data []byte) error { + type unmarshaler PlantPost + var body unmarshaler + if err := json.Unmarshal(data, &body); err != nil { + return err + } + *p = PlantPost(body) + return nil +} + +func (p *PlantPost) MarshalJSON() ([]byte, error) { + type embed PlantPost + var marshaler = struct { + embed + PlantedAt *internal.Date `json:"plantedAt,omitempty"` + }{ + embed: embed(*p), + PlantedAt: internal.NewOptionalDate(p.PlantedAt), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, p.explicitFields) + return json.Marshal(explicitMarshaler) +} + var ( ruleCreateRequestFieldName = big.NewInt(1 << 0) ruleCreateRequestFieldExecutionContext = big.NewInt(1 << 1) @@ -1410,82 +1528,24 @@ func (p *PagingCursors) String() string { return fmt.Sprintf("%#v", p) } -// Execution context for the rule, excluding the prod environment. -type RuleCreateRequestExecutionContext string - -const ( - RuleCreateRequestExecutionContextProd RuleCreateRequestExecutionContext = "prod" - RuleCreateRequestExecutionContextStaging RuleCreateRequestExecutionContext = "staging" - RuleCreateRequestExecutionContextDev RuleCreateRequestExecutionContext = "dev" -) - -func NewRuleCreateRequestExecutionContextFromString(s string) (RuleCreateRequestExecutionContext, error) { - switch s { - case "prod": - return RuleCreateRequestExecutionContextProd, nil - case "staging": - return RuleCreateRequestExecutionContextStaging, nil - case "dev": - return RuleCreateRequestExecutionContextDev, nil - } - var t RuleCreateRequestExecutionContext - return "", fmt.Errorf("%s is not a valid %T", s, t) -} - -func (r RuleCreateRequestExecutionContext) Ptr() *RuleCreateRequestExecutionContext { - return &r -} - -// Execution environment for a rule. -type RuleExecutionContext string - -const ( - RuleExecutionContextProd RuleExecutionContext = "prod" - RuleExecutionContextStaging RuleExecutionContext = "staging" - RuleExecutionContextDev RuleExecutionContext = "dev" -) - -func NewRuleExecutionContextFromString(s string) (RuleExecutionContext, error) { - switch s { - case "prod": - return RuleExecutionContextProd, nil - case "staging": - return RuleExecutionContextStaging, nil - case "dev": - return RuleExecutionContextDev, nil - } - var t RuleExecutionContext - return "", fmt.Errorf("%s is not a valid %T", s, t) -} - -func (r RuleExecutionContext) Ptr() *RuleExecutionContext { - return &r -} - var ( - ruleResponseFieldCreatedBy = big.NewInt(1 << 0) - ruleResponseFieldCreatedDateTime = big.NewInt(1 << 1) - ruleResponseFieldModifiedBy = big.NewInt(1 << 2) - ruleResponseFieldModifiedDateTime = big.NewInt(1 << 3) - ruleResponseFieldID = big.NewInt(1 << 4) - ruleResponseFieldName = big.NewInt(1 << 5) - ruleResponseFieldStatus = big.NewInt(1 << 6) - ruleResponseFieldExecutionContext = big.NewInt(1 << 7) + plantBaseFieldSpecies = big.NewInt(1 << 0) + plantBaseFieldFamily = big.NewInt(1 << 1) + plantBaseFieldGenus = big.NewInt(1 << 2) + plantBaseFieldCommonName = big.NewInt(1 << 3) + plantBaseFieldWateringFrequency = big.NewInt(1 << 4) ) -type RuleResponse struct { - // The user who created this resource. - CreatedBy *string `json:"createdBy,omitempty" url:"createdBy,omitempty"` - // When this resource was created. - CreatedDateTime *time.Time `json:"createdDateTime,omitempty" url:"createdDateTime,omitempty"` - // The user who last modified this resource. - ModifiedBy *string `json:"modifiedBy,omitempty" url:"modifiedBy,omitempty"` - // When this resource was last modified. - ModifiedDateTime *time.Time `json:"modifiedDateTime,omitempty" url:"modifiedDateTime,omitempty"` - ID string `json:"id" url:"id"` - Name string `json:"name" url:"name"` - Status RuleResponseStatus `json:"status" url:"status"` - ExecutionContext *RuleExecutionContext `json:"executionContext,omitempty" url:"executionContext,omitempty"` +type PlantBase struct { + // The botanical species name. + Species string `json:"species" url:"species"` + // The botanical family. + Family string `json:"family" url:"family"` + // The botanical genus. + Genus string `json:"genus" url:"genus"` + // The common name of the plant. + CommonName *string `json:"commonName,omitempty" url:"commonName,omitempty"` + WateringFrequency *PlantBaseWateringFrequency `json:"wateringFrequency,omitempty" url:"wateringFrequency,omitempty"` // Private bitmask of fields set to an explicit value and therefore not to be omitted explicitFields *big.Int `json:"-" url:"-"` @@ -1494,221 +1554,1002 @@ type RuleResponse struct { rawJSON json.RawMessage } -func (r *RuleResponse) GetCreatedBy() *string { - if r == nil { - return nil - } - return r.CreatedBy -} - -func (r *RuleResponse) GetCreatedDateTime() *time.Time { - if r == nil { - return nil - } - return r.CreatedDateTime -} - -func (r *RuleResponse) GetModifiedBy() *string { - if r == nil { - return nil - } - return r.ModifiedBy -} - -func (r *RuleResponse) GetModifiedDateTime() *time.Time { - if r == nil { - return nil - } - return r.ModifiedDateTime -} - -func (r *RuleResponse) GetID() string { - if r == nil { +func (p *PlantBase) GetSpecies() string { + if p == nil { return "" } - return r.ID + return p.Species } -func (r *RuleResponse) GetName() string { - if r == nil { +func (p *PlantBase) GetFamily() string { + if p == nil { return "" } - return r.Name + return p.Family } -func (r *RuleResponse) GetStatus() RuleResponseStatus { - if r == nil { +func (p *PlantBase) GetGenus() string { + if p == nil { return "" } - return r.Status + return p.Genus } -func (r *RuleResponse) GetExecutionContext() *RuleExecutionContext { - if r == nil { +func (p *PlantBase) GetCommonName() *string { + if p == nil { return nil } - return r.ExecutionContext + return p.CommonName } -func (r *RuleResponse) GetExtraProperties() map[string]interface{} { - if r == nil { +func (p *PlantBase) GetWateringFrequency() *PlantBaseWateringFrequency { + if p == nil { return nil } - return r.extraProperties + return p.WateringFrequency } -func (r *RuleResponse) require(field *big.Int) { - if r.explicitFields == nil { - r.explicitFields = big.NewInt(0) +func (p *PlantBase) GetExtraProperties() map[string]interface{} { + if p == nil { + return nil } - r.explicitFields.Or(r.explicitFields, field) -} - -// SetCreatedBy sets the CreatedBy field and marks it as non-optional; -// this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleResponse) SetCreatedBy(createdBy *string) { - r.CreatedBy = createdBy - r.require(ruleResponseFieldCreatedBy) -} - -// SetCreatedDateTime sets the CreatedDateTime field and marks it as non-optional; -// this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleResponse) SetCreatedDateTime(createdDateTime *time.Time) { - r.CreatedDateTime = createdDateTime - r.require(ruleResponseFieldCreatedDateTime) + return p.extraProperties } -// SetModifiedBy sets the ModifiedBy field and marks it as non-optional; -// this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleResponse) SetModifiedBy(modifiedBy *string) { - r.ModifiedBy = modifiedBy - r.require(ruleResponseFieldModifiedBy) +func (p *PlantBase) require(field *big.Int) { + if p.explicitFields == nil { + p.explicitFields = big.NewInt(0) + } + p.explicitFields.Or(p.explicitFields, field) } -// SetModifiedDateTime sets the ModifiedDateTime field and marks it as non-optional; +// SetSpecies sets the Species field and marks it as non-optional; // this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleResponse) SetModifiedDateTime(modifiedDateTime *time.Time) { - r.ModifiedDateTime = modifiedDateTime - r.require(ruleResponseFieldModifiedDateTime) +func (p *PlantBase) SetSpecies(species string) { + p.Species = species + p.require(plantBaseFieldSpecies) } -// SetID sets the ID field and marks it as non-optional; +// SetFamily sets the Family field and marks it as non-optional; // this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleResponse) SetID(id string) { - r.ID = id - r.require(ruleResponseFieldID) +func (p *PlantBase) SetFamily(family string) { + p.Family = family + p.require(plantBaseFieldFamily) } -// SetName sets the Name field and marks it as non-optional; +// SetGenus sets the Genus field and marks it as non-optional; // this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleResponse) SetName(name string) { - r.Name = name - r.require(ruleResponseFieldName) +func (p *PlantBase) SetGenus(genus string) { + p.Genus = genus + p.require(plantBaseFieldGenus) } -// SetStatus sets the Status field and marks it as non-optional; +// SetCommonName sets the CommonName field and marks it as non-optional; // this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleResponse) SetStatus(status RuleResponseStatus) { - r.Status = status - r.require(ruleResponseFieldStatus) +func (p *PlantBase) SetCommonName(commonName *string) { + p.CommonName = commonName + p.require(plantBaseFieldCommonName) } -// SetExecutionContext sets the ExecutionContext field and marks it as non-optional; +// SetWateringFrequency sets the WateringFrequency field and marks it as non-optional; // this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleResponse) SetExecutionContext(executionContext *RuleExecutionContext) { - r.ExecutionContext = executionContext - r.require(ruleResponseFieldExecutionContext) +func (p *PlantBase) SetWateringFrequency(wateringFrequency *PlantBaseWateringFrequency) { + p.WateringFrequency = wateringFrequency + p.require(plantBaseFieldWateringFrequency) } -func (r *RuleResponse) UnmarshalJSON(data []byte) error { - type embed RuleResponse - var unmarshaler = struct { - embed - CreatedDateTime *internal.DateTime `json:"createdDateTime,omitempty"` - ModifiedDateTime *internal.DateTime `json:"modifiedDateTime,omitempty"` - }{ - embed: embed(*r), - } - if err := json.Unmarshal(data, &unmarshaler); err != nil { +func (p *PlantBase) UnmarshalJSON(data []byte) error { + type unmarshaler PlantBase + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { return err } - *r = RuleResponse(unmarshaler.embed) - r.CreatedDateTime = unmarshaler.CreatedDateTime.TimePtr() - r.ModifiedDateTime = unmarshaler.ModifiedDateTime.TimePtr() - extraProperties, err := internal.ExtractExtraProperties(data, *r) + *p = PlantBase(value) + extraProperties, err := internal.ExtractExtraProperties(data, *p) if err != nil { return err } - r.extraProperties = extraProperties - r.rawJSON = json.RawMessage(data) + p.extraProperties = extraProperties + p.rawJSON = json.RawMessage(data) return nil } -func (r *RuleResponse) MarshalJSON() ([]byte, error) { - type embed RuleResponse +func (p *PlantBase) MarshalJSON() ([]byte, error) { + type embed PlantBase var marshaler = struct { embed - CreatedDateTime *internal.DateTime `json:"createdDateTime,omitempty"` - ModifiedDateTime *internal.DateTime `json:"modifiedDateTime,omitempty"` }{ - embed: embed(*r), - CreatedDateTime: internal.NewOptionalDateTime(r.CreatedDateTime), - ModifiedDateTime: internal.NewOptionalDateTime(r.ModifiedDateTime), + embed: embed(*p), } - explicitMarshaler := internal.HandleExplicitFields(marshaler, r.explicitFields) + explicitMarshaler := internal.HandleExplicitFields(marshaler, p.explicitFields) return json.Marshal(explicitMarshaler) } -func (r *RuleResponse) String() string { +func (p *PlantBase) String() string { + if p == nil { + return "" + } + if len(p.rawJSON) > 0 { + if value, err := internal.StringifyJSON(p.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(p); err == nil { + return value + } + return fmt.Sprintf("%#v", p) +} + +type PlantBaseWateringFrequency string + +const ( + PlantBaseWateringFrequencyDaily PlantBaseWateringFrequency = "daily" + PlantBaseWateringFrequencyWeekly PlantBaseWateringFrequency = "weekly" + PlantBaseWateringFrequencyBiweekly PlantBaseWateringFrequency = "biweekly" + PlantBaseWateringFrequencyMonthly PlantBaseWateringFrequency = "monthly" +) + +func NewPlantBaseWateringFrequencyFromString(s string) (PlantBaseWateringFrequency, error) { + switch s { + case "daily": + return PlantBaseWateringFrequencyDaily, nil + case "weekly": + return PlantBaseWateringFrequencyWeekly, nil + case "biweekly": + return PlantBaseWateringFrequencyBiweekly, nil + case "monthly": + return PlantBaseWateringFrequencyMonthly, nil + } + var t PlantBaseWateringFrequency + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (p PlantBaseWateringFrequency) Ptr() *PlantBaseWateringFrequency { + return &p +} + +// Required sun exposure level. +type PlantPostSunExposure string + +const ( + PlantPostSunExposureFull PlantPostSunExposure = "full" + PlantPostSunExposurePartial PlantPostSunExposure = "partial" + PlantPostSunExposureShade PlantPostSunExposure = "shade" +) + +func NewPlantPostSunExposureFromString(s string) (PlantPostSunExposure, error) { + switch s { + case "full": + return PlantPostSunExposureFull, nil + case "partial": + return PlantPostSunExposurePartial, nil + case "shade": + return PlantPostSunExposureShade, nil + } + var t PlantPostSunExposure + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (p PlantPostSunExposure) Ptr() *PlantPostSunExposure { + return &p +} + +type PlantPostWateringFrequency string + +const ( + PlantPostWateringFrequencyDaily PlantPostWateringFrequency = "daily" + PlantPostWateringFrequencyWeekly PlantPostWateringFrequency = "weekly" + PlantPostWateringFrequencyBiweekly PlantPostWateringFrequency = "biweekly" + PlantPostWateringFrequencyMonthly PlantPostWateringFrequency = "monthly" +) + +func NewPlantPostWateringFrequencyFromString(s string) (PlantPostWateringFrequency, error) { + switch s { + case "daily": + return PlantPostWateringFrequencyDaily, nil + case "weekly": + return PlantPostWateringFrequencyWeekly, nil + case "biweekly": + return PlantPostWateringFrequencyBiweekly, nil + case "monthly": + return PlantPostWateringFrequencyMonthly, nil + } + var t PlantPostWateringFrequency + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (p PlantPostWateringFrequency) Ptr() *PlantPostWateringFrequency { + return &p +} + +var ( + plantStrictFieldSpecies = big.NewInt(1 << 0) + plantStrictFieldFamily = big.NewInt(1 << 1) + plantStrictFieldGenus = big.NewInt(1 << 2) +) + +type PlantStrict struct { + // The botanical species name. + Species string `json:"species" url:"species"` + // The botanical family. + Family string `json:"family" url:"family"` + // The botanical genus. + Genus string `json:"genus" url:"genus"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (p *PlantStrict) GetSpecies() string { + if p == nil { + return "" + } + return p.Species +} + +func (p *PlantStrict) GetFamily() string { + if p == nil { + return "" + } + return p.Family +} + +func (p *PlantStrict) GetGenus() string { + if p == nil { + return "" + } + return p.Genus +} + +func (p *PlantStrict) GetExtraProperties() map[string]interface{} { + if p == nil { + return nil + } + return p.extraProperties +} + +func (p *PlantStrict) require(field *big.Int) { + if p.explicitFields == nil { + p.explicitFields = big.NewInt(0) + } + p.explicitFields.Or(p.explicitFields, field) +} + +// SetSpecies sets the Species field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantStrict) SetSpecies(species string) { + p.Species = species + p.require(plantStrictFieldSpecies) +} + +// SetFamily sets the Family field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantStrict) SetFamily(family string) { + p.Family = family + p.require(plantStrictFieldFamily) +} + +// SetGenus sets the Genus field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (p *PlantStrict) SetGenus(genus string) { + p.Genus = genus + p.require(plantStrictFieldGenus) +} + +func (p *PlantStrict) UnmarshalJSON(data []byte) error { + type unmarshaler PlantStrict + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *p = PlantStrict(value) + extraProperties, err := internal.ExtractExtraProperties(data, *p) + if err != nil { + return err + } + p.extraProperties = extraProperties + p.rawJSON = json.RawMessage(data) + return nil +} + +func (p *PlantStrict) MarshalJSON() ([]byte, error) { + type embed PlantStrict + var marshaler = struct { + embed + }{ + embed: embed(*p), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, p.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (p *PlantStrict) String() string { + if p == nil { + return "" + } + if len(p.rawJSON) > 0 { + if value, err := internal.StringifyJSON(p.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(p); err == nil { + return value + } + return fmt.Sprintf("%#v", p) +} + +// Execution context for the rule, excluding the prod environment. +type RuleCreateRequestExecutionContext string + +const ( + RuleCreateRequestExecutionContextProd RuleCreateRequestExecutionContext = "prod" + RuleCreateRequestExecutionContextStaging RuleCreateRequestExecutionContext = "staging" + RuleCreateRequestExecutionContextDev RuleCreateRequestExecutionContext = "dev" +) + +func NewRuleCreateRequestExecutionContextFromString(s string) (RuleCreateRequestExecutionContext, error) { + switch s { + case "prod": + return RuleCreateRequestExecutionContextProd, nil + case "staging": + return RuleCreateRequestExecutionContextStaging, nil + case "dev": + return RuleCreateRequestExecutionContextDev, nil + } + var t RuleCreateRequestExecutionContext + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (r RuleCreateRequestExecutionContext) Ptr() *RuleCreateRequestExecutionContext { + return &r +} + +// Execution environment for a rule. +type RuleExecutionContext string + +const ( + RuleExecutionContextProd RuleExecutionContext = "prod" + RuleExecutionContextStaging RuleExecutionContext = "staging" + RuleExecutionContextDev RuleExecutionContext = "dev" +) + +func NewRuleExecutionContextFromString(s string) (RuleExecutionContext, error) { + switch s { + case "prod": + return RuleExecutionContextProd, nil + case "staging": + return RuleExecutionContextStaging, nil + case "dev": + return RuleExecutionContextDev, nil + } + var t RuleExecutionContext + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (r RuleExecutionContext) Ptr() *RuleExecutionContext { + return &r +} + +var ( + ruleResponseFieldCreatedBy = big.NewInt(1 << 0) + ruleResponseFieldCreatedDateTime = big.NewInt(1 << 1) + ruleResponseFieldModifiedBy = big.NewInt(1 << 2) + ruleResponseFieldModifiedDateTime = big.NewInt(1 << 3) + ruleResponseFieldID = big.NewInt(1 << 4) + ruleResponseFieldName = big.NewInt(1 << 5) + ruleResponseFieldStatus = big.NewInt(1 << 6) + ruleResponseFieldExecutionContext = big.NewInt(1 << 7) +) + +type RuleResponse struct { + // The user who created this resource. + CreatedBy *string `json:"createdBy,omitempty" url:"createdBy,omitempty"` + // When this resource was created. + CreatedDateTime *time.Time `json:"createdDateTime,omitempty" url:"createdDateTime,omitempty"` + // The user who last modified this resource. + ModifiedBy *string `json:"modifiedBy,omitempty" url:"modifiedBy,omitempty"` + // When this resource was last modified. + ModifiedDateTime *time.Time `json:"modifiedDateTime,omitempty" url:"modifiedDateTime,omitempty"` + ID string `json:"id" url:"id"` + Name string `json:"name" url:"name"` + Status RuleResponseStatus `json:"status" url:"status"` + ExecutionContext *RuleExecutionContext `json:"executionContext,omitempty" url:"executionContext,omitempty"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (r *RuleResponse) GetCreatedBy() *string { + if r == nil { + return nil + } + return r.CreatedBy +} + +func (r *RuleResponse) GetCreatedDateTime() *time.Time { + if r == nil { + return nil + } + return r.CreatedDateTime +} + +func (r *RuleResponse) GetModifiedBy() *string { + if r == nil { + return nil + } + return r.ModifiedBy +} + +func (r *RuleResponse) GetModifiedDateTime() *time.Time { + if r == nil { + return nil + } + return r.ModifiedDateTime +} + +func (r *RuleResponse) GetID() string { + if r == nil { + return "" + } + return r.ID +} + +func (r *RuleResponse) GetName() string { + if r == nil { + return "" + } + return r.Name +} + +func (r *RuleResponse) GetStatus() RuleResponseStatus { + if r == nil { + return "" + } + return r.Status +} + +func (r *RuleResponse) GetExecutionContext() *RuleExecutionContext { + if r == nil { + return nil + } + return r.ExecutionContext +} + +func (r *RuleResponse) GetExtraProperties() map[string]interface{} { + if r == nil { + return nil + } + return r.extraProperties +} + +func (r *RuleResponse) require(field *big.Int) { + if r.explicitFields == nil { + r.explicitFields = big.NewInt(0) + } + r.explicitFields.Or(r.explicitFields, field) +} + +// SetCreatedBy sets the CreatedBy field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleResponse) SetCreatedBy(createdBy *string) { + r.CreatedBy = createdBy + r.require(ruleResponseFieldCreatedBy) +} + +// SetCreatedDateTime sets the CreatedDateTime field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleResponse) SetCreatedDateTime(createdDateTime *time.Time) { + r.CreatedDateTime = createdDateTime + r.require(ruleResponseFieldCreatedDateTime) +} + +// SetModifiedBy sets the ModifiedBy field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleResponse) SetModifiedBy(modifiedBy *string) { + r.ModifiedBy = modifiedBy + r.require(ruleResponseFieldModifiedBy) +} + +// SetModifiedDateTime sets the ModifiedDateTime field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleResponse) SetModifiedDateTime(modifiedDateTime *time.Time) { + r.ModifiedDateTime = modifiedDateTime + r.require(ruleResponseFieldModifiedDateTime) +} + +// SetID sets the ID field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleResponse) SetID(id string) { + r.ID = id + r.require(ruleResponseFieldID) +} + +// SetName sets the Name field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleResponse) SetName(name string) { + r.Name = name + r.require(ruleResponseFieldName) +} + +// SetStatus sets the Status field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleResponse) SetStatus(status RuleResponseStatus) { + r.Status = status + r.require(ruleResponseFieldStatus) +} + +// SetExecutionContext sets the ExecutionContext field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleResponse) SetExecutionContext(executionContext *RuleExecutionContext) { + r.ExecutionContext = executionContext + r.require(ruleResponseFieldExecutionContext) +} + +func (r *RuleResponse) UnmarshalJSON(data []byte) error { + type embed RuleResponse + var unmarshaler = struct { + embed + CreatedDateTime *internal.DateTime `json:"createdDateTime,omitempty"` + ModifiedDateTime *internal.DateTime `json:"modifiedDateTime,omitempty"` + }{ + embed: embed(*r), + } + if err := json.Unmarshal(data, &unmarshaler); err != nil { + return err + } + *r = RuleResponse(unmarshaler.embed) + r.CreatedDateTime = unmarshaler.CreatedDateTime.TimePtr() + r.ModifiedDateTime = unmarshaler.ModifiedDateTime.TimePtr() + extraProperties, err := internal.ExtractExtraProperties(data, *r) + if err != nil { + return err + } + r.extraProperties = extraProperties + r.rawJSON = json.RawMessage(data) + return nil +} + +func (r *RuleResponse) MarshalJSON() ([]byte, error) { + type embed RuleResponse + var marshaler = struct { + embed + CreatedDateTime *internal.DateTime `json:"createdDateTime,omitempty"` + ModifiedDateTime *internal.DateTime `json:"modifiedDateTime,omitempty"` + }{ + embed: embed(*r), + CreatedDateTime: internal.NewOptionalDateTime(r.CreatedDateTime), + ModifiedDateTime: internal.NewOptionalDateTime(r.ModifiedDateTime), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, r.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (r *RuleResponse) String() string { + if r == nil { + return "" + } + if len(r.rawJSON) > 0 { + if value, err := internal.StringifyJSON(r.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(r); err == nil { + return value + } + return fmt.Sprintf("%#v", r) +} + +type RuleResponseStatus string + +const ( + RuleResponseStatusActive RuleResponseStatus = "active" + RuleResponseStatusInactive RuleResponseStatus = "inactive" + RuleResponseStatusDraft RuleResponseStatus = "draft" +) + +func NewRuleResponseStatusFromString(s string) (RuleResponseStatus, error) { + switch s { + case "active": + return RuleResponseStatusActive, nil + case "inactive": + return RuleResponseStatusInactive, nil + case "draft": + return RuleResponseStatusDraft, nil + } + var t RuleResponseStatus + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (r RuleResponseStatus) Ptr() *RuleResponseStatus { + return &r +} + +var ( + ruleTypeFieldID = big.NewInt(1 << 0) + ruleTypeFieldName = big.NewInt(1 << 1) + ruleTypeFieldDescription = big.NewInt(1 << 2) +) + +type RuleType struct { + ID string `json:"id" url:"id"` + Name string `json:"name" url:"name"` + Description *string `json:"description,omitempty" url:"description,omitempty"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (r *RuleType) GetID() string { + if r == nil { + return "" + } + return r.ID +} + +func (r *RuleType) GetName() string { + if r == nil { + return "" + } + return r.Name +} + +func (r *RuleType) GetDescription() *string { + if r == nil { + return nil + } + return r.Description +} + +func (r *RuleType) GetExtraProperties() map[string]interface{} { + if r == nil { + return nil + } + return r.extraProperties +} + +func (r *RuleType) require(field *big.Int) { + if r.explicitFields == nil { + r.explicitFields = big.NewInt(0) + } + r.explicitFields.Or(r.explicitFields, field) +} + +// SetID sets the ID field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleType) SetID(id string) { + r.ID = id + r.require(ruleTypeFieldID) +} + +// SetName sets the Name field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleType) SetName(name string) { + r.Name = name + r.require(ruleTypeFieldName) +} + +// SetDescription sets the Description field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleType) SetDescription(description *string) { + r.Description = description + r.require(ruleTypeFieldDescription) +} + +func (r *RuleType) UnmarshalJSON(data []byte) error { + type unmarshaler RuleType + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *r = RuleType(value) + extraProperties, err := internal.ExtractExtraProperties(data, *r) + if err != nil { + return err + } + r.extraProperties = extraProperties + r.rawJSON = json.RawMessage(data) + return nil +} + +func (r *RuleType) MarshalJSON() ([]byte, error) { + type embed RuleType + var marshaler = struct { + embed + }{ + embed: embed(*r), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, r.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (r *RuleType) String() string { + if r == nil { + return "" + } + if len(r.rawJSON) > 0 { + if value, err := internal.StringifyJSON(r.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(r); err == nil { + return value + } + return fmt.Sprintf("%#v", r) +} + +var ( + ruleTypeSearchResponseFieldPaging = big.NewInt(1 << 0) + ruleTypeSearchResponseFieldResults = big.NewInt(1 << 1) +) + +type RuleTypeSearchResponse struct { + Paging *PagingCursors `json:"paging" url:"paging"` + // Current page of results from the requested resource. + Results []*RuleType `json:"results,omitempty" url:"results,omitempty"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (r *RuleTypeSearchResponse) GetPaging() *PagingCursors { + if r == nil { + return nil + } + return r.Paging +} + +func (r *RuleTypeSearchResponse) GetResults() []*RuleType { + if r == nil { + return nil + } + return r.Results +} + +func (r *RuleTypeSearchResponse) GetExtraProperties() map[string]interface{} { + if r == nil { + return nil + } + return r.extraProperties +} + +func (r *RuleTypeSearchResponse) require(field *big.Int) { + if r.explicitFields == nil { + r.explicitFields = big.NewInt(0) + } + r.explicitFields.Or(r.explicitFields, field) +} + +// SetPaging sets the Paging field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleTypeSearchResponse) SetPaging(paging *PagingCursors) { + r.Paging = paging + r.require(ruleTypeSearchResponseFieldPaging) +} + +// SetResults sets the Results field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (r *RuleTypeSearchResponse) SetResults(results []*RuleType) { + r.Results = results + r.require(ruleTypeSearchResponseFieldResults) +} + +func (r *RuleTypeSearchResponse) UnmarshalJSON(data []byte) error { + type unmarshaler RuleTypeSearchResponse + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *r = RuleTypeSearchResponse(value) + extraProperties, err := internal.ExtractExtraProperties(data, *r) + if err != nil { + return err + } + r.extraProperties = extraProperties + r.rawJSON = json.RawMessage(data) + return nil +} + +func (r *RuleTypeSearchResponse) MarshalJSON() ([]byte, error) { + type embed RuleTypeSearchResponse + var marshaler = struct { + embed + }{ + embed: embed(*r), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, r.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (r *RuleTypeSearchResponse) String() string { if r == nil { return "" } - if len(r.rawJSON) > 0 { - if value, err := internal.StringifyJSON(r.rawJSON); err == nil { - return value - } + if len(r.rawJSON) > 0 { + if value, err := internal.StringifyJSON(r.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(r); err == nil { + return value + } + return fmt.Sprintf("%#v", r) +} + +var ( + treeBaseFieldID = big.NewInt(1 << 0) + treeBaseFieldTreeName = big.NewInt(1 << 1) + treeBaseFieldTreeDescription = big.NewInt(1 << 2) + treeBaseFieldTreeSpecies = big.NewInt(1 << 3) + treeBaseFieldHeightInFeet = big.NewInt(1 << 4) +) + +type TreeBase struct { + // Unique tree identifier. + ID string `json:"id" url:"id"` + // Display name of the tree. + TreeName *string `json:"treeName,omitempty" url:"treeName,omitempty"` + // A description of the tree. + TreeDescription *string `json:"treeDescription,omitempty" url:"treeDescription,omitempty"` + // The species of tree. + TreeSpecies *string `json:"treeSpecies,omitempty" url:"treeSpecies,omitempty"` + // Height of the tree in feet. + HeightInFeet *float64 `json:"heightInFeet,omitempty" url:"heightInFeet,omitempty"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (t *TreeBase) GetID() string { + if t == nil { + return "" + } + return t.ID +} + +func (t *TreeBase) GetTreeName() *string { + if t == nil { + return nil + } + return t.TreeName +} + +func (t *TreeBase) GetTreeDescription() *string { + if t == nil { + return nil + } + return t.TreeDescription +} + +func (t *TreeBase) GetTreeSpecies() *string { + if t == nil { + return nil + } + return t.TreeSpecies +} + +func (t *TreeBase) GetHeightInFeet() *float64 { + if t == nil { + return nil } - if value, err := internal.StringifyJSON(r); err == nil { - return value + return t.HeightInFeet +} + +func (t *TreeBase) GetExtraProperties() map[string]interface{} { + if t == nil { + return nil } - return fmt.Sprintf("%#v", r) + return t.extraProperties } -type RuleResponseStatus string +func (t *TreeBase) require(field *big.Int) { + if t.explicitFields == nil { + t.explicitFields = big.NewInt(0) + } + t.explicitFields.Or(t.explicitFields, field) +} -const ( - RuleResponseStatusActive RuleResponseStatus = "active" - RuleResponseStatusInactive RuleResponseStatus = "inactive" - RuleResponseStatusDraft RuleResponseStatus = "draft" -) +// SetID sets the ID field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeBase) SetID(id string) { + t.ID = id + t.require(treeBaseFieldID) +} -func NewRuleResponseStatusFromString(s string) (RuleResponseStatus, error) { - switch s { - case "active": - return RuleResponseStatusActive, nil - case "inactive": - return RuleResponseStatusInactive, nil - case "draft": - return RuleResponseStatusDraft, nil +// SetTreeName sets the TreeName field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeBase) SetTreeName(treeName *string) { + t.TreeName = treeName + t.require(treeBaseFieldTreeName) +} + +// SetTreeDescription sets the TreeDescription field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeBase) SetTreeDescription(treeDescription *string) { + t.TreeDescription = treeDescription + t.require(treeBaseFieldTreeDescription) +} + +// SetTreeSpecies sets the TreeSpecies field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeBase) SetTreeSpecies(treeSpecies *string) { + t.TreeSpecies = treeSpecies + t.require(treeBaseFieldTreeSpecies) +} + +// SetHeightInFeet sets the HeightInFeet field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeBase) SetHeightInFeet(heightInFeet *float64) { + t.HeightInFeet = heightInFeet + t.require(treeBaseFieldHeightInFeet) +} + +func (t *TreeBase) UnmarshalJSON(data []byte) error { + type unmarshaler TreeBase + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err } - var t RuleResponseStatus - return "", fmt.Errorf("%s is not a valid %T", s, t) + *t = TreeBase(value) + extraProperties, err := internal.ExtractExtraProperties(data, *t) + if err != nil { + return err + } + t.extraProperties = extraProperties + t.rawJSON = json.RawMessage(data) + return nil } -func (r RuleResponseStatus) Ptr() *RuleResponseStatus { - return &r +func (t *TreeBase) MarshalJSON() ([]byte, error) { + type embed TreeBase + var marshaler = struct { + embed + }{ + embed: embed(*t), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, t.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (t *TreeBase) String() string { + if t == nil { + return "" + } + if len(t.rawJSON) > 0 { + if value, err := internal.StringifyJSON(t.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(t); err == nil { + return value + } + return fmt.Sprintf("%#v", t) } var ( - ruleTypeFieldID = big.NewInt(1 << 0) - ruleTypeFieldName = big.NewInt(1 << 1) - ruleTypeFieldDescription = big.NewInt(1 << 2) + treeDescribableFieldTreeName = big.NewInt(1 << 0) + treeDescribableFieldTreeDescription = big.NewInt(1 << 1) ) -type RuleType struct { - ID string `json:"id" url:"id"` - Name string `json:"name" url:"name"` - Description *string `json:"description,omitempty" url:"description,omitempty"` +type TreeDescribable struct { + // Display name of the tree. + TreeName *string `json:"treeName,omitempty" url:"treeName,omitempty"` + // A description of the tree. + TreeDescription *string `json:"treeDescription,omitempty" url:"treeDescription,omitempty"` // Private bitmask of fields set to an explicit value and therefore not to be omitted explicitFields *big.Int `json:"-" url:"-"` @@ -1717,113 +2558,197 @@ type RuleType struct { rawJSON json.RawMessage } -func (r *RuleType) GetID() string { - if r == nil { - return "" - } - return r.ID -} - -func (r *RuleType) GetName() string { - if r == nil { - return "" +func (t *TreeDescribable) GetTreeName() *string { + if t == nil { + return nil } - return r.Name + return t.TreeName } -func (r *RuleType) GetDescription() *string { - if r == nil { +func (t *TreeDescribable) GetTreeDescription() *string { + if t == nil { return nil } - return r.Description + return t.TreeDescription } -func (r *RuleType) GetExtraProperties() map[string]interface{} { - if r == nil { +func (t *TreeDescribable) GetExtraProperties() map[string]interface{} { + if t == nil { return nil } - return r.extraProperties + return t.extraProperties } -func (r *RuleType) require(field *big.Int) { - if r.explicitFields == nil { - r.explicitFields = big.NewInt(0) +func (t *TreeDescribable) require(field *big.Int) { + if t.explicitFields == nil { + t.explicitFields = big.NewInt(0) } - r.explicitFields.Or(r.explicitFields, field) + t.explicitFields.Or(t.explicitFields, field) } -// SetID sets the ID field and marks it as non-optional; +// SetTreeName sets the TreeName field and marks it as non-optional; // this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleType) SetID(id string) { - r.ID = id - r.require(ruleTypeFieldID) +func (t *TreeDescribable) SetTreeName(treeName *string) { + t.TreeName = treeName + t.require(treeDescribableFieldTreeName) } -// SetName sets the Name field and marks it as non-optional; +// SetTreeDescription sets the TreeDescription field and marks it as non-optional; // this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleType) SetName(name string) { - r.Name = name - r.require(ruleTypeFieldName) +func (t *TreeDescribable) SetTreeDescription(treeDescription *string) { + t.TreeDescription = treeDescription + t.require(treeDescribableFieldTreeDescription) } -// SetDescription sets the Description field and marks it as non-optional; +func (t *TreeDescribable) UnmarshalJSON(data []byte) error { + type unmarshaler TreeDescribable + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *t = TreeDescribable(value) + extraProperties, err := internal.ExtractExtraProperties(data, *t) + if err != nil { + return err + } + t.extraProperties = extraProperties + t.rawJSON = json.RawMessage(data) + return nil +} + +func (t *TreeDescribable) MarshalJSON() ([]byte, error) { + type embed TreeDescribable + var marshaler = struct { + embed + }{ + embed: embed(*t), + } + explicitMarshaler := internal.HandleExplicitFields(marshaler, t.explicitFields) + return json.Marshal(explicitMarshaler) +} + +func (t *TreeDescribable) String() string { + if t == nil { + return "" + } + if len(t.rawJSON) > 0 { + if value, err := internal.StringifyJSON(t.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(t); err == nil { + return value + } + return fmt.Sprintf("%#v", t) +} + +var ( + treeIdentifiableFieldID = big.NewInt(1 << 0) +) + +type TreeIdentifiable struct { + // Unique tree identifier. + ID string `json:"id" url:"id"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (t *TreeIdentifiable) GetID() string { + if t == nil { + return "" + } + return t.ID +} + +func (t *TreeIdentifiable) GetExtraProperties() map[string]interface{} { + if t == nil { + return nil + } + return t.extraProperties +} + +func (t *TreeIdentifiable) require(field *big.Int) { + if t.explicitFields == nil { + t.explicitFields = big.NewInt(0) + } + t.explicitFields.Or(t.explicitFields, field) +} + +// SetID sets the ID field and marks it as non-optional; // this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleType) SetDescription(description *string) { - r.Description = description - r.require(ruleTypeFieldDescription) +func (t *TreeIdentifiable) SetID(id string) { + t.ID = id + t.require(treeIdentifiableFieldID) } -func (r *RuleType) UnmarshalJSON(data []byte) error { - type unmarshaler RuleType +func (t *TreeIdentifiable) UnmarshalJSON(data []byte) error { + type unmarshaler TreeIdentifiable var value unmarshaler if err := json.Unmarshal(data, &value); err != nil { return err } - *r = RuleType(value) - extraProperties, err := internal.ExtractExtraProperties(data, *r) + *t = TreeIdentifiable(value) + extraProperties, err := internal.ExtractExtraProperties(data, *t) if err != nil { return err } - r.extraProperties = extraProperties - r.rawJSON = json.RawMessage(data) + t.extraProperties = extraProperties + t.rawJSON = json.RawMessage(data) return nil } -func (r *RuleType) MarshalJSON() ([]byte, error) { - type embed RuleType +func (t *TreeIdentifiable) MarshalJSON() ([]byte, error) { + type embed TreeIdentifiable var marshaler = struct { embed }{ - embed: embed(*r), + embed: embed(*t), } - explicitMarshaler := internal.HandleExplicitFields(marshaler, r.explicitFields) + explicitMarshaler := internal.HandleExplicitFields(marshaler, t.explicitFields) return json.Marshal(explicitMarshaler) } -func (r *RuleType) String() string { - if r == nil { +func (t *TreeIdentifiable) String() string { + if t == nil { return "" } - if len(r.rawJSON) > 0 { - if value, err := internal.StringifyJSON(r.rawJSON); err == nil { + if len(t.rawJSON) > 0 { + if value, err := internal.StringifyJSON(t.rawJSON); err == nil { return value } } - if value, err := internal.StringifyJSON(r); err == nil { + if value, err := internal.StringifyJSON(t); err == nil { return value } - return fmt.Sprintf("%#v", r) + return fmt.Sprintf("%#v", t) } var ( - ruleTypeSearchResponseFieldPaging = big.NewInt(1 << 0) - ruleTypeSearchResponseFieldResults = big.NewInt(1 << 1) + treeRecordFieldID = big.NewInt(1 << 0) + treeRecordFieldTreeName = big.NewInt(1 << 1) + treeRecordFieldTreeDescription = big.NewInt(1 << 2) + treeRecordFieldTreeSpecies = big.NewInt(1 << 3) + treeRecordFieldHeightInFeet = big.NewInt(1 << 4) + treeRecordFieldPlantedDate = big.NewInt(1 << 5) ) -type RuleTypeSearchResponse struct { - Paging *PagingCursors `json:"paging" url:"paging"` - // Current page of results from the requested resource. - Results []*RuleType `json:"results,omitempty" url:"results,omitempty"` +type TreeRecord struct { + // Unique tree identifier. + ID string `json:"id" url:"id"` + // Display name of the tree. + TreeName string `json:"treeName" url:"treeName"` + // A description of the tree. + TreeDescription *string `json:"treeDescription,omitempty" url:"treeDescription,omitempty"` + // The species of tree. + TreeSpecies string `json:"treeSpecies" url:"treeSpecies"` + // Height of the tree in feet. + HeightInFeet *float64 `json:"heightInFeet,omitempty" url:"heightInFeet,omitempty"` + // Date the tree was planted. + PlantedDate *time.Time `json:"plantedDate,omitempty" url:"plantedDate,omitempty" format:"date"` // Private bitmask of fields set to an explicit value and therefore not to be omitted explicitFields *big.Int `json:"-" url:"-"` @@ -1832,88 +2757,152 @@ type RuleTypeSearchResponse struct { rawJSON json.RawMessage } -func (r *RuleTypeSearchResponse) GetPaging() *PagingCursors { - if r == nil { +func (t *TreeRecord) GetID() string { + if t == nil { + return "" + } + return t.ID +} + +func (t *TreeRecord) GetTreeName() string { + if t == nil { + return "" + } + return t.TreeName +} + +func (t *TreeRecord) GetTreeDescription() *string { + if t == nil { return nil } - return r.Paging + return t.TreeDescription } -func (r *RuleTypeSearchResponse) GetResults() []*RuleType { - if r == nil { +func (t *TreeRecord) GetTreeSpecies() string { + if t == nil { + return "" + } + return t.TreeSpecies +} + +func (t *TreeRecord) GetHeightInFeet() *float64 { + if t == nil { return nil } - return r.Results + return t.HeightInFeet } -func (r *RuleTypeSearchResponse) GetExtraProperties() map[string]interface{} { - if r == nil { +func (t *TreeRecord) GetPlantedDate() *time.Time { + if t == nil { return nil } - return r.extraProperties + return t.PlantedDate } -func (r *RuleTypeSearchResponse) require(field *big.Int) { - if r.explicitFields == nil { - r.explicitFields = big.NewInt(0) +func (t *TreeRecord) GetExtraProperties() map[string]interface{} { + if t == nil { + return nil } - r.explicitFields.Or(r.explicitFields, field) + return t.extraProperties } -// SetPaging sets the Paging field and marks it as non-optional; +func (t *TreeRecord) require(field *big.Int) { + if t.explicitFields == nil { + t.explicitFields = big.NewInt(0) + } + t.explicitFields.Or(t.explicitFields, field) +} + +// SetID sets the ID field and marks it as non-optional; // this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleTypeSearchResponse) SetPaging(paging *PagingCursors) { - r.Paging = paging - r.require(ruleTypeSearchResponseFieldPaging) +func (t *TreeRecord) SetID(id string) { + t.ID = id + t.require(treeRecordFieldID) } -// SetResults sets the Results field and marks it as non-optional; +// SetTreeName sets the TreeName field and marks it as non-optional; // this prevents an empty or null value for this field from being omitted during serialization. -func (r *RuleTypeSearchResponse) SetResults(results []*RuleType) { - r.Results = results - r.require(ruleTypeSearchResponseFieldResults) +func (t *TreeRecord) SetTreeName(treeName string) { + t.TreeName = treeName + t.require(treeRecordFieldTreeName) } -func (r *RuleTypeSearchResponse) UnmarshalJSON(data []byte) error { - type unmarshaler RuleTypeSearchResponse - var value unmarshaler - if err := json.Unmarshal(data, &value); err != nil { +// SetTreeDescription sets the TreeDescription field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeRecord) SetTreeDescription(treeDescription *string) { + t.TreeDescription = treeDescription + t.require(treeRecordFieldTreeDescription) +} + +// SetTreeSpecies sets the TreeSpecies field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeRecord) SetTreeSpecies(treeSpecies string) { + t.TreeSpecies = treeSpecies + t.require(treeRecordFieldTreeSpecies) +} + +// SetHeightInFeet sets the HeightInFeet field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeRecord) SetHeightInFeet(heightInFeet *float64) { + t.HeightInFeet = heightInFeet + t.require(treeRecordFieldHeightInFeet) +} + +// SetPlantedDate sets the PlantedDate field and marks it as non-optional; +// this prevents an empty or null value for this field from being omitted during serialization. +func (t *TreeRecord) SetPlantedDate(plantedDate *time.Time) { + t.PlantedDate = plantedDate + t.require(treeRecordFieldPlantedDate) +} + +func (t *TreeRecord) UnmarshalJSON(data []byte) error { + type embed TreeRecord + var unmarshaler = struct { + embed + PlantedDate *internal.Date `json:"plantedDate,omitempty"` + }{ + embed: embed(*t), + } + if err := json.Unmarshal(data, &unmarshaler); err != nil { return err } - *r = RuleTypeSearchResponse(value) - extraProperties, err := internal.ExtractExtraProperties(data, *r) + *t = TreeRecord(unmarshaler.embed) + t.PlantedDate = unmarshaler.PlantedDate.TimePtr() + extraProperties, err := internal.ExtractExtraProperties(data, *t) if err != nil { return err } - r.extraProperties = extraProperties - r.rawJSON = json.RawMessage(data) + t.extraProperties = extraProperties + t.rawJSON = json.RawMessage(data) return nil } -func (r *RuleTypeSearchResponse) MarshalJSON() ([]byte, error) { - type embed RuleTypeSearchResponse +func (t *TreeRecord) MarshalJSON() ([]byte, error) { + type embed TreeRecord var marshaler = struct { embed + PlantedDate *internal.Date `json:"plantedDate,omitempty"` }{ - embed: embed(*r), + embed: embed(*t), + PlantedDate: internal.NewOptionalDate(t.PlantedDate), } - explicitMarshaler := internal.HandleExplicitFields(marshaler, r.explicitFields) + explicitMarshaler := internal.HandleExplicitFields(marshaler, t.explicitFields) return json.Marshal(explicitMarshaler) } -func (r *RuleTypeSearchResponse) String() string { - if r == nil { +func (t *TreeRecord) String() string { + if t == nil { return "" } - if len(r.rawJSON) > 0 { - if value, err := internal.StringifyJSON(r.rawJSON); err == nil { + if len(t.rawJSON) > 0 { + if value, err := internal.StringifyJSON(t.rawJSON); err == nil { return value } } - if value, err := internal.StringifyJSON(r); err == nil { + if value, err := internal.StringifyJSON(t); err == nil { return value } - return fmt.Sprintf("%#v", r) + return fmt.Sprintf("%#v", t) } var ( diff --git a/seed/go-sdk/allof-inline/types_test.go b/seed/go-sdk/allof-inline/types_test.go index cd9038d8d56d..d211abf499b0 100644 --- a/seed/go-sdk/allof-inline/types_test.go +++ b/seed/go-sdk/allof-inline/types_test.go @@ -10,6 +10,324 @@ import ( time "time" ) +func TestSettersPlantPost(t *testing.T) { + t.Run("SetSpecies", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValueSpecies string + obj.SetSpecies(fernTestValueSpecies) + assert.Equal(t, fernTestValueSpecies, obj.Species) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetFamily", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValueFamily string + obj.SetFamily(fernTestValueFamily) + assert.Equal(t, fernTestValueFamily, obj.Family) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetGenus", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValueGenus string + obj.SetGenus(fernTestValueGenus) + assert.Equal(t, fernTestValueGenus, obj.Genus) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetCommonName", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValueCommonName string + obj.SetCommonName(fernTestValueCommonName) + assert.Equal(t, fernTestValueCommonName, obj.CommonName) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetWateringFrequency", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValueWateringFrequency PlantPostWateringFrequency + obj.SetWateringFrequency(fernTestValueWateringFrequency) + assert.Equal(t, fernTestValueWateringFrequency, obj.WateringFrequency) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetSunExposure", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValueSunExposure PlantPostSunExposure + obj.SetSunExposure(fernTestValueSunExposure) + assert.Equal(t, fernTestValueSunExposure, obj.SunExposure) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetPlantedAt", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValuePlantedAt *time.Time + obj.SetPlantedAt(fernTestValuePlantedAt) + assert.Equal(t, fernTestValuePlantedAt, obj.PlantedAt) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetSoilType", func(t *testing.T) { + obj := &PlantPost{} + var fernTestValueSoilType *string + obj.SetSoilType(fernTestValueSoilType) + assert.Equal(t, fernTestValueSoilType, obj.SoilType) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestSettersMarkExplicitPlantPost(t *testing.T) { + t.Run("SetSpecies_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValueSpecies string + + // Act + obj.SetSpecies(fernTestValueSpecies) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetFamily_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValueFamily string + + // Act + obj.SetFamily(fernTestValueFamily) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetGenus_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValueGenus string + + // Act + obj.SetGenus(fernTestValueGenus) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetCommonName_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValueCommonName string + + // Act + obj.SetCommonName(fernTestValueCommonName) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetWateringFrequency_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValueWateringFrequency PlantPostWateringFrequency + + // Act + obj.SetWateringFrequency(fernTestValueWateringFrequency) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetSunExposure_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValueSunExposure PlantPostSunExposure + + // Act + obj.SetSunExposure(fernTestValueSunExposure) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetPlantedAt_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValuePlantedAt *time.Time + + // Act + obj.SetPlantedAt(fernTestValuePlantedAt) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetSoilType_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantPost{} + var fernTestValueSoilType *string + + // Act + obj.SetSoilType(fernTestValueSoilType) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + func TestSettersRuleCreateRequest(t *testing.T) { t.Run("SetName", func(t *testing.T) { obj := &RuleCreateRequest{} @@ -2163,319 +2481,196 @@ func TestSettersMarkExplicitPagingCursors(t *testing.T) { } -func TestSettersRuleResponse(t *testing.T) { - t.Run("SetCreatedBy", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueCreatedBy *string - obj.SetCreatedBy(fernTestValueCreatedBy) - assert.Equal(t, fernTestValueCreatedBy, obj.CreatedBy) +func TestSettersPlantBase(t *testing.T) { + t.Run("SetSpecies", func(t *testing.T) { + obj := &PlantBase{} + var fernTestValueSpecies string + obj.SetSpecies(fernTestValueSpecies) + assert.Equal(t, fernTestValueSpecies, obj.Species) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetCreatedDateTime", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueCreatedDateTime *time.Time - obj.SetCreatedDateTime(fernTestValueCreatedDateTime) - assert.Equal(t, fernTestValueCreatedDateTime, obj.CreatedDateTime) + t.Run("SetFamily", func(t *testing.T) { + obj := &PlantBase{} + var fernTestValueFamily string + obj.SetFamily(fernTestValueFamily) + assert.Equal(t, fernTestValueFamily, obj.Family) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetModifiedBy", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueModifiedBy *string - obj.SetModifiedBy(fernTestValueModifiedBy) - assert.Equal(t, fernTestValueModifiedBy, obj.ModifiedBy) - assert.NotNil(t, obj.explicitFields) - }) - - t.Run("SetModifiedDateTime", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueModifiedDateTime *time.Time - obj.SetModifiedDateTime(fernTestValueModifiedDateTime) - assert.Equal(t, fernTestValueModifiedDateTime, obj.ModifiedDateTime) - assert.NotNil(t, obj.explicitFields) - }) - - t.Run("SetID", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueID string - obj.SetID(fernTestValueID) - assert.Equal(t, fernTestValueID, obj.ID) - assert.NotNil(t, obj.explicitFields) - }) - - t.Run("SetName", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueName string - obj.SetName(fernTestValueName) - assert.Equal(t, fernTestValueName, obj.Name) + t.Run("SetGenus", func(t *testing.T) { + obj := &PlantBase{} + var fernTestValueGenus string + obj.SetGenus(fernTestValueGenus) + assert.Equal(t, fernTestValueGenus, obj.Genus) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetStatus", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueStatus RuleResponseStatus - obj.SetStatus(fernTestValueStatus) - assert.Equal(t, fernTestValueStatus, obj.Status) + t.Run("SetCommonName", func(t *testing.T) { + obj := &PlantBase{} + var fernTestValueCommonName *string + obj.SetCommonName(fernTestValueCommonName) + assert.Equal(t, fernTestValueCommonName, obj.CommonName) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetExecutionContext", func(t *testing.T) { - obj := &RuleResponse{} - var fernTestValueExecutionContext *RuleExecutionContext - obj.SetExecutionContext(fernTestValueExecutionContext) - assert.Equal(t, fernTestValueExecutionContext, obj.ExecutionContext) + t.Run("SetWateringFrequency", func(t *testing.T) { + obj := &PlantBase{} + var fernTestValueWateringFrequency *PlantBaseWateringFrequency + obj.SetWateringFrequency(fernTestValueWateringFrequency) + assert.Equal(t, fernTestValueWateringFrequency, obj.WateringFrequency) assert.NotNil(t, obj.explicitFields) }) } -func TestGettersRuleResponse(t *testing.T) { - t.Run("GetCreatedBy", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var expected *string - obj.CreatedBy = expected - - // Act & Assert - assert.Equal(t, expected, obj.GetCreatedBy(), "getter should return the property value") - }) - - t.Run("GetCreatedBy_NilValue", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - obj.CreatedBy = nil - - // Act & Assert - assert.Nil(t, obj.GetCreatedBy(), "getter should return nil when property is nil") - }) - - t.Run("GetCreatedBy_NilReceiver", func(t *testing.T) { - t.Parallel() - var obj *RuleResponse - // Should not panic - getters should handle nil receiver gracefully - defer func() { - if r := recover(); r != nil { - t.Errorf("Getter panicked on nil receiver: %v", r) - } - }() - _ = obj.GetCreatedBy() // Should return zero value - }) - - t.Run("GetCreatedDateTime", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var expected *time.Time - obj.CreatedDateTime = expected - - // Act & Assert - assert.Equal(t, expected, obj.GetCreatedDateTime(), "getter should return the property value") - }) - - t.Run("GetCreatedDateTime_NilValue", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - obj.CreatedDateTime = nil - - // Act & Assert - assert.Nil(t, obj.GetCreatedDateTime(), "getter should return nil when property is nil") - }) - - t.Run("GetCreatedDateTime_NilReceiver", func(t *testing.T) { - t.Parallel() - var obj *RuleResponse - // Should not panic - getters should handle nil receiver gracefully - defer func() { - if r := recover(); r != nil { - t.Errorf("Getter panicked on nil receiver: %v", r) - } - }() - _ = obj.GetCreatedDateTime() // Should return zero value - }) - - t.Run("GetModifiedBy", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var expected *string - obj.ModifiedBy = expected - - // Act & Assert - assert.Equal(t, expected, obj.GetModifiedBy(), "getter should return the property value") - }) - - t.Run("GetModifiedBy_NilValue", func(t *testing.T) { +func TestGettersPlantBase(t *testing.T) { + t.Run("GetSpecies", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - obj.ModifiedBy = nil + obj := &PlantBase{} + var expected string + obj.Species = expected // Act & Assert - assert.Nil(t, obj.GetModifiedBy(), "getter should return nil when property is nil") + assert.Equal(t, expected, obj.GetSpecies(), "getter should return the property value") }) - t.Run("GetModifiedBy_NilReceiver", func(t *testing.T) { + t.Run("GetSpecies_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *PlantBase // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetModifiedBy() // Should return zero value - }) - - t.Run("GetModifiedDateTime", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var expected *time.Time - obj.ModifiedDateTime = expected - - // Act & Assert - assert.Equal(t, expected, obj.GetModifiedDateTime(), "getter should return the property value") + _ = obj.GetSpecies() // Should return zero value }) - t.Run("GetModifiedDateTime_NilValue", func(t *testing.T) { + t.Run("GetFamily", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - obj.ModifiedDateTime = nil + obj := &PlantBase{} + var expected string + obj.Family = expected // Act & Assert - assert.Nil(t, obj.GetModifiedDateTime(), "getter should return nil when property is nil") + assert.Equal(t, expected, obj.GetFamily(), "getter should return the property value") }) - t.Run("GetModifiedDateTime_NilReceiver", func(t *testing.T) { + t.Run("GetFamily_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *PlantBase // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetModifiedDateTime() // Should return zero value + _ = obj.GetFamily() // Should return zero value }) - t.Run("GetID", func(t *testing.T) { + t.Run("GetGenus", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} + obj := &PlantBase{} var expected string - obj.ID = expected + obj.Genus = expected // Act & Assert - assert.Equal(t, expected, obj.GetID(), "getter should return the property value") + assert.Equal(t, expected, obj.GetGenus(), "getter should return the property value") }) - t.Run("GetID_NilReceiver", func(t *testing.T) { + t.Run("GetGenus_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *PlantBase // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetID() // Should return zero value + _ = obj.GetGenus() // Should return zero value }) - t.Run("GetName", func(t *testing.T) { + t.Run("GetCommonName", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var expected string - obj.Name = expected + obj := &PlantBase{} + var expected *string + obj.CommonName = expected // Act & Assert - assert.Equal(t, expected, obj.GetName(), "getter should return the property value") - }) - - t.Run("GetName_NilReceiver", func(t *testing.T) { - t.Parallel() - var obj *RuleResponse - // Should not panic - getters should handle nil receiver gracefully - defer func() { - if r := recover(); r != nil { - t.Errorf("Getter panicked on nil receiver: %v", r) - } - }() - _ = obj.GetName() // Should return zero value + assert.Equal(t, expected, obj.GetCommonName(), "getter should return the property value") }) - t.Run("GetStatus", func(t *testing.T) { + t.Run("GetCommonName_NilValue", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var expected RuleResponseStatus - obj.Status = expected + obj := &PlantBase{} + obj.CommonName = nil // Act & Assert - assert.Equal(t, expected, obj.GetStatus(), "getter should return the property value") + assert.Nil(t, obj.GetCommonName(), "getter should return nil when property is nil") }) - t.Run("GetStatus_NilReceiver", func(t *testing.T) { + t.Run("GetCommonName_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *PlantBase // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetStatus() // Should return zero value + _ = obj.GetCommonName() // Should return zero value }) - t.Run("GetExecutionContext", func(t *testing.T) { + t.Run("GetWateringFrequency", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var expected *RuleExecutionContext - obj.ExecutionContext = expected + obj := &PlantBase{} + var expected *PlantBaseWateringFrequency + obj.WateringFrequency = expected // Act & Assert - assert.Equal(t, expected, obj.GetExecutionContext(), "getter should return the property value") + assert.Equal(t, expected, obj.GetWateringFrequency(), "getter should return the property value") }) - t.Run("GetExecutionContext_NilValue", func(t *testing.T) { + t.Run("GetWateringFrequency_NilValue", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - obj.ExecutionContext = nil + obj := &PlantBase{} + obj.WateringFrequency = nil // Act & Assert - assert.Nil(t, obj.GetExecutionContext(), "getter should return nil when property is nil") + assert.Nil(t, obj.GetWateringFrequency(), "getter should return nil when property is nil") }) - t.Run("GetExecutionContext_NilReceiver", func(t *testing.T) { + t.Run("GetWateringFrequency_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *PlantBase // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetExecutionContext() // Should return zero value + _ = obj.GetWateringFrequency() // Should return zero value }) } -func TestSettersMarkExplicitRuleResponse(t *testing.T) { - t.Run("SetCreatedBy_MarksExplicit", func(t *testing.T) { +func TestSettersMarkExplicitPlantBase(t *testing.T) { + t.Run("SetSpecies_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueCreatedBy *string + obj := &PlantBase{} + var fernTestValueSpecies string // Act - obj.SetCreatedBy(fernTestValueCreatedBy) + obj.SetSpecies(fernTestValueSpecies) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2499,14 +2694,14 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetCreatedDateTime_MarksExplicit", func(t *testing.T) { + t.Run("SetFamily_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueCreatedDateTime *time.Time + obj := &PlantBase{} + var fernTestValueFamily string // Act - obj.SetCreatedDateTime(fernTestValueCreatedDateTime) + obj.SetFamily(fernTestValueFamily) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2530,14 +2725,14 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetModifiedBy_MarksExplicit", func(t *testing.T) { + t.Run("SetGenus_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueModifiedBy *string + obj := &PlantBase{} + var fernTestValueGenus string // Act - obj.SetModifiedBy(fernTestValueModifiedBy) + obj.SetGenus(fernTestValueGenus) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2561,14 +2756,14 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetModifiedDateTime_MarksExplicit", func(t *testing.T) { + t.Run("SetCommonName_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueModifiedDateTime *time.Time + obj := &PlantBase{} + var fernTestValueCommonName *string // Act - obj.SetModifiedDateTime(fernTestValueModifiedDateTime) + obj.SetCommonName(fernTestValueCommonName) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2592,14 +2787,14 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetID_MarksExplicit", func(t *testing.T) { + t.Run("SetWateringFrequency_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueID string + obj := &PlantBase{} + var fernTestValueWateringFrequency *PlantBaseWateringFrequency // Act - obj.SetID(fernTestValueID) + obj.SetWateringFrequency(fernTestValueWateringFrequency) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2623,51 +2818,1578 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetName_MarksExplicit", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleResponse{} - var fernTestValueName string - - // Act - obj.SetName(fernTestValueName) +} - // Assert - object with explicitly set field can be marshaled/unmarshaled - bytes, err := json.Marshal(obj) - require.NoError(t, err, "marshaling should succeed for test setup") +func TestSettersPlantStrict(t *testing.T) { + t.Run("SetSpecies", func(t *testing.T) { + obj := &PlantStrict{} + var fernTestValueSpecies string + obj.SetSpecies(fernTestValueSpecies) + assert.Equal(t, fernTestValueSpecies, obj.Species) + assert.NotNil(t, obj.explicitFields) + }) - // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value - // Detect if marshaled JSON is an object or primitive to use correct unmarshal target - if len(bytes) > 0 && bytes[0] == '{' { - // JSON object - unmarshal into map - var unmarshaled map[string]interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } else { - // JSON primitive (string, number, boolean, null) - unmarshal into interface{} - var unmarshaled interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } + t.Run("SetFamily", func(t *testing.T) { + obj := &PlantStrict{} + var fernTestValueFamily string + obj.SetFamily(fernTestValueFamily) + assert.Equal(t, fernTestValueFamily, obj.Family) + assert.NotNil(t, obj.explicitFields) + }) - // Note: This does not explicitly assert the presence of a specific JSON field - // It verifies that setting a field via setter allows successful JSON round-trip + t.Run("SetGenus", func(t *testing.T) { + obj := &PlantStrict{} + var fernTestValueGenus string + obj.SetGenus(fernTestValueGenus) + assert.Equal(t, fernTestValueGenus, obj.Genus) + assert.NotNil(t, obj.explicitFields) }) - t.Run("SetStatus_MarksExplicit", func(t *testing.T) { +} + +func TestGettersPlantStrict(t *testing.T) { + t.Run("GetSpecies", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueStatus RuleResponseStatus - - // Act - obj.SetStatus(fernTestValueStatus) + obj := &PlantStrict{} + var expected string + obj.Species = expected - // Assert - object with explicitly set field can be marshaled/unmarshaled - bytes, err := json.Marshal(obj) - require.NoError(t, err, "marshaling should succeed for test setup") + // Act & Assert + assert.Equal(t, expected, obj.GetSpecies(), "getter should return the property value") + }) - // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + t.Run("GetSpecies_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *PlantStrict + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetSpecies() // Should return zero value + }) + + t.Run("GetFamily", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantStrict{} + var expected string + obj.Family = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetFamily(), "getter should return the property value") + }) + + t.Run("GetFamily_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *PlantStrict + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetFamily() // Should return zero value + }) + + t.Run("GetGenus", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantStrict{} + var expected string + obj.Genus = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetGenus(), "getter should return the property value") + }) + + t.Run("GetGenus_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *PlantStrict + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetGenus() // Should return zero value + }) + +} + +func TestSettersMarkExplicitPlantStrict(t *testing.T) { + t.Run("SetSpecies_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantStrict{} + var fernTestValueSpecies string + + // Act + obj.SetSpecies(fernTestValueSpecies) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetFamily_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantStrict{} + var fernTestValueFamily string + + // Act + obj.SetFamily(fernTestValueFamily) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetGenus_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &PlantStrict{} + var fernTestValueGenus string + + // Act + obj.SetGenus(fernTestValueGenus) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersRuleResponse(t *testing.T) { + t.Run("SetCreatedBy", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueCreatedBy *string + obj.SetCreatedBy(fernTestValueCreatedBy) + assert.Equal(t, fernTestValueCreatedBy, obj.CreatedBy) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetCreatedDateTime", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueCreatedDateTime *time.Time + obj.SetCreatedDateTime(fernTestValueCreatedDateTime) + assert.Equal(t, fernTestValueCreatedDateTime, obj.CreatedDateTime) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetModifiedBy", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueModifiedBy *string + obj.SetModifiedBy(fernTestValueModifiedBy) + assert.Equal(t, fernTestValueModifiedBy, obj.ModifiedBy) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetModifiedDateTime", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueModifiedDateTime *time.Time + obj.SetModifiedDateTime(fernTestValueModifiedDateTime) + assert.Equal(t, fernTestValueModifiedDateTime, obj.ModifiedDateTime) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetID", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueID string + obj.SetID(fernTestValueID) + assert.Equal(t, fernTestValueID, obj.ID) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetName", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueName string + obj.SetName(fernTestValueName) + assert.Equal(t, fernTestValueName, obj.Name) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetStatus", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueStatus RuleResponseStatus + obj.SetStatus(fernTestValueStatus) + assert.Equal(t, fernTestValueStatus, obj.Status) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetExecutionContext", func(t *testing.T) { + obj := &RuleResponse{} + var fernTestValueExecutionContext *RuleExecutionContext + obj.SetExecutionContext(fernTestValueExecutionContext) + assert.Equal(t, fernTestValueExecutionContext, obj.ExecutionContext) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersRuleResponse(t *testing.T) { + t.Run("GetCreatedBy", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected *string + obj.CreatedBy = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetCreatedBy(), "getter should return the property value") + }) + + t.Run("GetCreatedBy_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + obj.CreatedBy = nil + + // Act & Assert + assert.Nil(t, obj.GetCreatedBy(), "getter should return nil when property is nil") + }) + + t.Run("GetCreatedBy_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetCreatedBy() // Should return zero value + }) + + t.Run("GetCreatedDateTime", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected *time.Time + obj.CreatedDateTime = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetCreatedDateTime(), "getter should return the property value") + }) + + t.Run("GetCreatedDateTime_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + obj.CreatedDateTime = nil + + // Act & Assert + assert.Nil(t, obj.GetCreatedDateTime(), "getter should return nil when property is nil") + }) + + t.Run("GetCreatedDateTime_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetCreatedDateTime() // Should return zero value + }) + + t.Run("GetModifiedBy", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected *string + obj.ModifiedBy = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetModifiedBy(), "getter should return the property value") + }) + + t.Run("GetModifiedBy_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + obj.ModifiedBy = nil + + // Act & Assert + assert.Nil(t, obj.GetModifiedBy(), "getter should return nil when property is nil") + }) + + t.Run("GetModifiedBy_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetModifiedBy() // Should return zero value + }) + + t.Run("GetModifiedDateTime", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected *time.Time + obj.ModifiedDateTime = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetModifiedDateTime(), "getter should return the property value") + }) + + t.Run("GetModifiedDateTime_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + obj.ModifiedDateTime = nil + + // Act & Assert + assert.Nil(t, obj.GetModifiedDateTime(), "getter should return nil when property is nil") + }) + + t.Run("GetModifiedDateTime_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetModifiedDateTime() // Should return zero value + }) + + t.Run("GetID", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected string + obj.ID = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetID(), "getter should return the property value") + }) + + t.Run("GetID_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetID() // Should return zero value + }) + + t.Run("GetName", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected string + obj.Name = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetName(), "getter should return the property value") + }) + + t.Run("GetName_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetName() // Should return zero value + }) + + t.Run("GetStatus", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected RuleResponseStatus + obj.Status = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetStatus(), "getter should return the property value") + }) + + t.Run("GetStatus_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetStatus() // Should return zero value + }) + + t.Run("GetExecutionContext", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var expected *RuleExecutionContext + obj.ExecutionContext = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetExecutionContext(), "getter should return the property value") + }) + + t.Run("GetExecutionContext_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + obj.ExecutionContext = nil + + // Act & Assert + assert.Nil(t, obj.GetExecutionContext(), "getter should return nil when property is nil") + }) + + t.Run("GetExecutionContext_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetExecutionContext() // Should return zero value + }) + +} + +func TestSettersMarkExplicitRuleResponse(t *testing.T) { + t.Run("SetCreatedBy_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueCreatedBy *string + + // Act + obj.SetCreatedBy(fernTestValueCreatedBy) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetCreatedDateTime_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueCreatedDateTime *time.Time + + // Act + obj.SetCreatedDateTime(fernTestValueCreatedDateTime) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetModifiedBy_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueModifiedBy *string + + // Act + obj.SetModifiedBy(fernTestValueModifiedBy) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetModifiedDateTime_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueModifiedDateTime *time.Time + + // Act + obj.SetModifiedDateTime(fernTestValueModifiedDateTime) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetID_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueID string + + // Act + obj.SetID(fernTestValueID) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetName_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueName string + + // Act + obj.SetName(fernTestValueName) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetStatus_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueStatus RuleResponseStatus + + // Act + obj.SetStatus(fernTestValueStatus) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetExecutionContext_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleResponse{} + var fernTestValueExecutionContext *RuleExecutionContext + + // Act + obj.SetExecutionContext(fernTestValueExecutionContext) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersRuleType(t *testing.T) { + t.Run("SetID", func(t *testing.T) { + obj := &RuleType{} + var fernTestValueID string + obj.SetID(fernTestValueID) + assert.Equal(t, fernTestValueID, obj.ID) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetName", func(t *testing.T) { + obj := &RuleType{} + var fernTestValueName string + obj.SetName(fernTestValueName) + assert.Equal(t, fernTestValueName, obj.Name) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetDescription", func(t *testing.T) { + obj := &RuleType{} + var fernTestValueDescription *string + obj.SetDescription(fernTestValueDescription) + assert.Equal(t, fernTestValueDescription, obj.Description) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersRuleType(t *testing.T) { + t.Run("GetID", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var expected string + obj.ID = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetID(), "getter should return the property value") + }) + + t.Run("GetID_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleType + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetID() // Should return zero value + }) + + t.Run("GetName", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var expected string + obj.Name = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetName(), "getter should return the property value") + }) + + t.Run("GetName_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleType + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetName() // Should return zero value + }) + + t.Run("GetDescription", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var expected *string + obj.Description = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetDescription(), "getter should return the property value") + }) + + t.Run("GetDescription_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + obj.Description = nil + + // Act & Assert + assert.Nil(t, obj.GetDescription(), "getter should return nil when property is nil") + }) + + t.Run("GetDescription_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleType + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetDescription() // Should return zero value + }) + +} + +func TestSettersMarkExplicitRuleType(t *testing.T) { + t.Run("SetID_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var fernTestValueID string + + // Act + obj.SetID(fernTestValueID) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetName_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var fernTestValueName string + + // Act + obj.SetName(fernTestValueName) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetDescription_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleType{} + var fernTestValueDescription *string + + // Act + obj.SetDescription(fernTestValueDescription) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersRuleTypeSearchResponse(t *testing.T) { + t.Run("SetPaging", func(t *testing.T) { + obj := &RuleTypeSearchResponse{} + var fernTestValuePaging *PagingCursors + obj.SetPaging(fernTestValuePaging) + assert.Equal(t, fernTestValuePaging, obj.Paging) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetResults", func(t *testing.T) { + obj := &RuleTypeSearchResponse{} + var fernTestValueResults []*RuleType + obj.SetResults(fernTestValueResults) + assert.Equal(t, fernTestValueResults, obj.Results) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersRuleTypeSearchResponse(t *testing.T) { + t.Run("GetPaging", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + var expected *PagingCursors + obj.Paging = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetPaging(), "getter should return the property value") + }) + + t.Run("GetPaging_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + obj.Paging = nil + + // Act & Assert + assert.Nil(t, obj.GetPaging(), "getter should return nil when property is nil") + }) + + t.Run("GetPaging_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleTypeSearchResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetPaging() // Should return zero value + }) + + t.Run("GetResults", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + var expected []*RuleType + obj.Results = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetResults(), "getter should return the property value") + }) + + t.Run("GetResults_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + obj.Results = nil + + // Act & Assert + assert.Nil(t, obj.GetResults(), "getter should return nil when property is nil") + }) + + t.Run("GetResults_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *RuleTypeSearchResponse + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetResults() // Should return zero value + }) + +} + +func TestSettersMarkExplicitRuleTypeSearchResponse(t *testing.T) { + t.Run("SetPaging_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + var fernTestValuePaging *PagingCursors + + // Act + obj.SetPaging(fernTestValuePaging) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetResults_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &RuleTypeSearchResponse{} + var fernTestValueResults []*RuleType + + // Act + obj.SetResults(fernTestValueResults) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersTreeBase(t *testing.T) { + t.Run("SetID", func(t *testing.T) { + obj := &TreeBase{} + var fernTestValueID string + obj.SetID(fernTestValueID) + assert.Equal(t, fernTestValueID, obj.ID) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeName", func(t *testing.T) { + obj := &TreeBase{} + var fernTestValueTreeName *string + obj.SetTreeName(fernTestValueTreeName) + assert.Equal(t, fernTestValueTreeName, obj.TreeName) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeDescription", func(t *testing.T) { + obj := &TreeBase{} + var fernTestValueTreeDescription *string + obj.SetTreeDescription(fernTestValueTreeDescription) + assert.Equal(t, fernTestValueTreeDescription, obj.TreeDescription) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeSpecies", func(t *testing.T) { + obj := &TreeBase{} + var fernTestValueTreeSpecies *string + obj.SetTreeSpecies(fernTestValueTreeSpecies) + assert.Equal(t, fernTestValueTreeSpecies, obj.TreeSpecies) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetHeightInFeet", func(t *testing.T) { + obj := &TreeBase{} + var fernTestValueHeightInFeet *float64 + obj.SetHeightInFeet(fernTestValueHeightInFeet) + assert.Equal(t, fernTestValueHeightInFeet, obj.HeightInFeet) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersTreeBase(t *testing.T) { + t.Run("GetID", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var expected string + obj.ID = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetID(), "getter should return the property value") + }) + + t.Run("GetID_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetID() // Should return zero value + }) + + t.Run("GetTreeName", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var expected *string + obj.TreeName = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeName(), "getter should return the property value") + }) + + t.Run("GetTreeName_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + obj.TreeName = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeName(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeName_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeName() // Should return zero value + }) + + t.Run("GetTreeDescription", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var expected *string + obj.TreeDescription = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeDescription(), "getter should return the property value") + }) + + t.Run("GetTreeDescription_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + obj.TreeDescription = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeDescription(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeDescription_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeDescription() // Should return zero value + }) + + t.Run("GetTreeSpecies", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var expected *string + obj.TreeSpecies = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeSpecies(), "getter should return the property value") + }) + + t.Run("GetTreeSpecies_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + obj.TreeSpecies = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeSpecies(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeSpecies_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeSpecies() // Should return zero value + }) + + t.Run("GetHeightInFeet", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var expected *float64 + obj.HeightInFeet = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetHeightInFeet(), "getter should return the property value") + }) + + t.Run("GetHeightInFeet_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + obj.HeightInFeet = nil + + // Act & Assert + assert.Nil(t, obj.GetHeightInFeet(), "getter should return nil when property is nil") + }) + + t.Run("GetHeightInFeet_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetHeightInFeet() // Should return zero value + }) + +} + +func TestSettersMarkExplicitTreeBase(t *testing.T) { + t.Run("SetID_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var fernTestValueID string + + // Act + obj.SetID(fernTestValueID) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetTreeName_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var fernTestValueTreeName *string + + // Act + obj.SetTreeName(fernTestValueTreeName) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetTreeDescription_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var fernTestValueTreeDescription *string + + // Act + obj.SetTreeDescription(fernTestValueTreeDescription) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetTreeSpecies_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var fernTestValueTreeSpecies *string + + // Act + obj.SetTreeSpecies(fernTestValueTreeSpecies) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + + t.Run("SetHeightInFeet_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeBase{} + var fernTestValueHeightInFeet *float64 + + // Act + obj.SetHeightInFeet(fernTestValueHeightInFeet) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersTreeDescribable(t *testing.T) { + t.Run("SetTreeName", func(t *testing.T) { + obj := &TreeDescribable{} + var fernTestValueTreeName *string + obj.SetTreeName(fernTestValueTreeName) + assert.Equal(t, fernTestValueTreeName, obj.TreeName) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeDescription", func(t *testing.T) { + obj := &TreeDescribable{} + var fernTestValueTreeDescription *string + obj.SetTreeDescription(fernTestValueTreeDescription) + assert.Equal(t, fernTestValueTreeDescription, obj.TreeDescription) + assert.NotNil(t, obj.explicitFields) + }) + +} + +func TestGettersTreeDescribable(t *testing.T) { + t.Run("GetTreeName", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + var expected *string + obj.TreeName = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeName(), "getter should return the property value") + }) + + t.Run("GetTreeName_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + obj.TreeName = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeName(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeName_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeDescribable + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeName() // Should return zero value + }) + + t.Run("GetTreeDescription", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + var expected *string + obj.TreeDescription = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeDescription(), "getter should return the property value") + }) + + t.Run("GetTreeDescription_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + obj.TreeDescription = nil + + // Act & Assert + assert.Nil(t, obj.GetTreeDescription(), "getter should return nil when property is nil") + }) + + t.Run("GetTreeDescription_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeDescribable + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeDescription() // Should return zero value + }) + +} + +func TestSettersMarkExplicitTreeDescribable(t *testing.T) { + t.Run("SetTreeName_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeDescribable{} + var fernTestValueTreeName *string + + // Act + obj.SetTreeName(fernTestValueTreeName) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value // Detect if marshaled JSON is an object or primitive to use correct unmarshal target if len(bytes) > 0 && bytes[0] == '{' { // JSON object - unmarshal into map @@ -2685,14 +4407,14 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetExecutionContext_MarksExplicit", func(t *testing.T) { + t.Run("SetTreeDescription_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} - var fernTestValueExecutionContext *RuleExecutionContext + obj := &TreeDescribable{} + var fernTestValueTreeDescription *string // Act - obj.SetExecutionContext(fernTestValueExecutionContext) + obj.SetTreeDescription(fernTestValueTreeDescription) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2718,38 +4440,133 @@ func TestSettersMarkExplicitRuleResponse(t *testing.T) { } -func TestSettersRuleType(t *testing.T) { +func TestSettersTreeIdentifiable(t *testing.T) { t.Run("SetID", func(t *testing.T) { - obj := &RuleType{} + obj := &TreeIdentifiable{} var fernTestValueID string obj.SetID(fernTestValueID) assert.Equal(t, fernTestValueID, obj.ID) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetName", func(t *testing.T) { - obj := &RuleType{} - var fernTestValueName string - obj.SetName(fernTestValueName) - assert.Equal(t, fernTestValueName, obj.Name) +} + +func TestGettersTreeIdentifiable(t *testing.T) { + t.Run("GetID", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeIdentifiable{} + var expected string + obj.ID = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetID(), "getter should return the property value") + }) + + t.Run("GetID_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeIdentifiable + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetID() // Should return zero value + }) + +} + +func TestSettersMarkExplicitTreeIdentifiable(t *testing.T) { + t.Run("SetID_MarksExplicit", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeIdentifiable{} + var fernTestValueID string + + // Act + obj.SetID(fernTestValueID) + + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestSettersTreeRecord(t *testing.T) { + t.Run("SetID", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValueID string + obj.SetID(fernTestValueID) + assert.Equal(t, fernTestValueID, obj.ID) assert.NotNil(t, obj.explicitFields) }) - t.Run("SetDescription", func(t *testing.T) { - obj := &RuleType{} - var fernTestValueDescription *string - obj.SetDescription(fernTestValueDescription) - assert.Equal(t, fernTestValueDescription, obj.Description) + t.Run("SetTreeName", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValueTreeName string + obj.SetTreeName(fernTestValueTreeName) + assert.Equal(t, fernTestValueTreeName, obj.TreeName) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeDescription", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValueTreeDescription *string + obj.SetTreeDescription(fernTestValueTreeDescription) + assert.Equal(t, fernTestValueTreeDescription, obj.TreeDescription) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetTreeSpecies", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValueTreeSpecies string + obj.SetTreeSpecies(fernTestValueTreeSpecies) + assert.Equal(t, fernTestValueTreeSpecies, obj.TreeSpecies) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetHeightInFeet", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValueHeightInFeet *float64 + obj.SetHeightInFeet(fernTestValueHeightInFeet) + assert.Equal(t, fernTestValueHeightInFeet, obj.HeightInFeet) + assert.NotNil(t, obj.explicitFields) + }) + + t.Run("SetPlantedDate", func(t *testing.T) { + obj := &TreeRecord{} + var fernTestValuePlantedDate *time.Time + obj.SetPlantedDate(fernTestValuePlantedDate) + assert.Equal(t, fernTestValuePlantedDate, obj.PlantedDate) assert.NotNil(t, obj.explicitFields) }) } -func TestGettersRuleType(t *testing.T) { +func TestGettersTreeRecord(t *testing.T) { t.Run("GetID", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} + obj := &TreeRecord{} var expected string obj.ID = expected @@ -2759,7 +4576,7 @@ func TestGettersRuleType(t *testing.T) { t.Run("GetID_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleType + var obj *TreeRecord // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { @@ -2769,69 +4586,158 @@ func TestGettersRuleType(t *testing.T) { _ = obj.GetID() // Should return zero value }) - t.Run("GetName", func(t *testing.T) { + t.Run("GetTreeName", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} + obj := &TreeRecord{} var expected string - obj.Name = expected + obj.TreeName = expected // Act & Assert - assert.Equal(t, expected, obj.GetName(), "getter should return the property value") + assert.Equal(t, expected, obj.GetTreeName(), "getter should return the property value") }) - t.Run("GetName_NilReceiver", func(t *testing.T) { + t.Run("GetTreeName_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleType + var obj *TreeRecord // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetName() // Should return zero value + _ = obj.GetTreeName() // Should return zero value }) - t.Run("GetDescription", func(t *testing.T) { + t.Run("GetTreeDescription", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} + obj := &TreeRecord{} var expected *string - obj.Description = expected + obj.TreeDescription = expected // Act & Assert - assert.Equal(t, expected, obj.GetDescription(), "getter should return the property value") + assert.Equal(t, expected, obj.GetTreeDescription(), "getter should return the property value") }) - t.Run("GetDescription_NilValue", func(t *testing.T) { + t.Run("GetTreeDescription_NilValue", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} - obj.Description = nil + obj := &TreeRecord{} + obj.TreeDescription = nil // Act & Assert - assert.Nil(t, obj.GetDescription(), "getter should return nil when property is nil") + assert.Nil(t, obj.GetTreeDescription(), "getter should return nil when property is nil") }) - t.Run("GetDescription_NilReceiver", func(t *testing.T) { + t.Run("GetTreeDescription_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleType + var obj *TreeRecord // Should not panic - getters should handle nil receiver gracefully defer func() { if r := recover(); r != nil { t.Errorf("Getter panicked on nil receiver: %v", r) } }() - _ = obj.GetDescription() // Should return zero value + _ = obj.GetTreeDescription() // Should return zero value + }) + + t.Run("GetTreeSpecies", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var expected string + obj.TreeSpecies = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetTreeSpecies(), "getter should return the property value") + }) + + t.Run("GetTreeSpecies_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetTreeSpecies() // Should return zero value + }) + + t.Run("GetHeightInFeet", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var expected *float64 + obj.HeightInFeet = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetHeightInFeet(), "getter should return the property value") + }) + + t.Run("GetHeightInFeet_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + obj.HeightInFeet = nil + + // Act & Assert + assert.Nil(t, obj.GetHeightInFeet(), "getter should return nil when property is nil") + }) + + t.Run("GetHeightInFeet_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetHeightInFeet() // Should return zero value + }) + + t.Run("GetPlantedDate", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + var expected *time.Time + obj.PlantedDate = expected + + // Act & Assert + assert.Equal(t, expected, obj.GetPlantedDate(), "getter should return the property value") + }) + + t.Run("GetPlantedDate_NilValue", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &TreeRecord{} + obj.PlantedDate = nil + + // Act & Assert + assert.Nil(t, obj.GetPlantedDate(), "getter should return nil when property is nil") + }) + + t.Run("GetPlantedDate_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + // Should not panic - getters should handle nil receiver gracefully + defer func() { + if r := recover(); r != nil { + t.Errorf("Getter panicked on nil receiver: %v", r) + } + }() + _ = obj.GetPlantedDate() // Should return zero value }) } -func TestSettersMarkExplicitRuleType(t *testing.T) { +func TestSettersMarkExplicitTreeRecord(t *testing.T) { t.Run("SetID_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} + obj := &TreeRecord{} var fernTestValueID string // Act @@ -2859,14 +4765,14 @@ func TestSettersMarkExplicitRuleType(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetName_MarksExplicit", func(t *testing.T) { + t.Run("SetTreeName_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} - var fernTestValueName string + obj := &TreeRecord{} + var fernTestValueTreeName string // Act - obj.SetName(fernTestValueName) + obj.SetTreeName(fernTestValueTreeName) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2890,14 +4796,14 @@ func TestSettersMarkExplicitRuleType(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetDescription_MarksExplicit", func(t *testing.T) { + t.Run("SetTreeDescription_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} - var fernTestValueDescription *string + obj := &TreeRecord{} + var fernTestValueTreeDescription *string // Act - obj.SetDescription(fernTestValueDescription) + obj.SetTreeDescription(fernTestValueTreeDescription) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -2912,114 +4818,54 @@ func TestSettersMarkExplicitRuleType(t *testing.T) { require.NoError(t, err, "unmarshaling should succeed for test verification") } else { // JSON primitive (string, number, boolean, null) - unmarshal into interface{} - var unmarshaled interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } - - // Note: This does not explicitly assert the presence of a specific JSON field - // It verifies that setting a field via setter allows successful JSON round-trip - }) - -} - -func TestSettersRuleTypeSearchResponse(t *testing.T) { - t.Run("SetPaging", func(t *testing.T) { - obj := &RuleTypeSearchResponse{} - var fernTestValuePaging *PagingCursors - obj.SetPaging(fernTestValuePaging) - assert.Equal(t, fernTestValuePaging, obj.Paging) - assert.NotNil(t, obj.explicitFields) - }) - - t.Run("SetResults", func(t *testing.T) { - obj := &RuleTypeSearchResponse{} - var fernTestValueResults []*RuleType - obj.SetResults(fernTestValueResults) - assert.Equal(t, fernTestValueResults, obj.Results) - assert.NotNil(t, obj.explicitFields) - }) - -} - -func TestGettersRuleTypeSearchResponse(t *testing.T) { - t.Run("GetPaging", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleTypeSearchResponse{} - var expected *PagingCursors - obj.Paging = expected - - // Act & Assert - assert.Equal(t, expected, obj.GetPaging(), "getter should return the property value") - }) - - t.Run("GetPaging_NilValue", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleTypeSearchResponse{} - obj.Paging = nil - - // Act & Assert - assert.Nil(t, obj.GetPaging(), "getter should return nil when property is nil") - }) - - t.Run("GetPaging_NilReceiver", func(t *testing.T) { - t.Parallel() - var obj *RuleTypeSearchResponse - // Should not panic - getters should handle nil receiver gracefully - defer func() { - if r := recover(); r != nil { - t.Errorf("Getter panicked on nil receiver: %v", r) - } - }() - _ = obj.GetPaging() // Should return zero value + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("GetResults", func(t *testing.T) { + t.Run("SetTreeSpecies_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} - var expected []*RuleType - obj.Results = expected + obj := &TreeRecord{} + var fernTestValueTreeSpecies string - // Act & Assert - assert.Equal(t, expected, obj.GetResults(), "getter should return the property value") - }) + // Act + obj.SetTreeSpecies(fernTestValueTreeSpecies) - t.Run("GetResults_NilValue", func(t *testing.T) { - t.Parallel() - // Arrange - obj := &RuleTypeSearchResponse{} - obj.Results = nil + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") - // Act & Assert - assert.Nil(t, obj.GetResults(), "getter should return nil when property is nil") - }) + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } - t.Run("GetResults_NilReceiver", func(t *testing.T) { - t.Parallel() - var obj *RuleTypeSearchResponse - // Should not panic - getters should handle nil receiver gracefully - defer func() { - if r := recover(); r != nil { - t.Errorf("Getter panicked on nil receiver: %v", r) - } - }() - _ = obj.GetResults() // Should return zero value + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip }) -} - -func TestSettersMarkExplicitRuleTypeSearchResponse(t *testing.T) { - t.Run("SetPaging_MarksExplicit", func(t *testing.T) { + t.Run("SetHeightInFeet_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} - var fernTestValuePaging *PagingCursors + obj := &TreeRecord{} + var fernTestValueHeightInFeet *float64 // Act - obj.SetPaging(fernTestValuePaging) + obj.SetHeightInFeet(fernTestValueHeightInFeet) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -3043,14 +4889,14 @@ func TestSettersMarkExplicitRuleTypeSearchResponse(t *testing.T) { // It verifies that setting a field via setter allows successful JSON round-trip }) - t.Run("SetResults_MarksExplicit", func(t *testing.T) { + t.Run("SetPlantedDate_MarksExplicit", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} - var fernTestValueResults []*RuleType + obj := &TreeRecord{} + var fernTestValuePlantedDate *time.Time // Act - obj.SetResults(fernTestValueResults) + obj.SetPlantedDate(fernTestValuePlantedDate) // Assert - object with explicitly set field can be marshaled/unmarshaled bytes, err := json.Marshal(obj) @@ -3338,35 +5184,233 @@ func TestSettersMarkExplicitUserSearchResponse(t *testing.T) { // Act obj.SetResults(fernTestValueResults) - // Assert - object with explicitly set field can be marshaled/unmarshaled - bytes, err := json.Marshal(obj) - require.NoError(t, err, "marshaling should succeed for test setup") + // Assert - object with explicitly set field can be marshaled/unmarshaled + bytes, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed for test setup") + + // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value + // Detect if marshaled JSON is an object or primitive to use correct unmarshal target + if len(bytes) > 0 && bytes[0] == '{' { + // JSON object - unmarshal into map + var unmarshaled map[string]interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } else { + // JSON primitive (string, number, boolean, null) - unmarshal into interface{} + var unmarshaled interface{} + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err, "unmarshaling should succeed for test verification") + } + + // Note: This does not explicitly assert the presence of a specific JSON field + // It verifies that setting a field via setter allows successful JSON round-trip + }) + +} + +func TestJSONMarshalingAuditInfo(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &AuditInfo{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled AuditInfo + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj AuditInfo + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj AuditInfo + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + +func TestJSONMarshalingBaseOrg(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &BaseOrg{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled BaseOrg + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj BaseOrg + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj BaseOrg + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + +func TestJSONMarshalingBaseOrgMetadata(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &BaseOrgMetadata{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled BaseOrgMetadata + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj BaseOrgMetadata + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj BaseOrgMetadata + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + +func TestJSONMarshalingCombinedEntity(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &CombinedEntity{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled CombinedEntity + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj CombinedEntity + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj CombinedEntity + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + +func TestJSONMarshalingDescribable(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &Describable{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") + + // Unmarshal back and verify round-trip + var unmarshaled Describable + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) + + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj Describable + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") + }) + + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj Describable + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) +} + +func TestJSONMarshalingDetailedOrg(t *testing.T) { + t.Run("MarshalUnmarshal", func(t *testing.T) { + t.Parallel() + // Arrange + obj := &DetailedOrg{} + + // Act - Marshal to JSON + data, err := json.Marshal(obj) + require.NoError(t, err, "marshaling should succeed") + assert.NotNil(t, data, "marshaled data should not be nil") + assert.NotEmpty(t, data, "marshaled data should not be empty") - // This test ensures JSON marshaling and unmarshaling succeed when the field has a zero/nil value - // Detect if marshaled JSON is an object or primitive to use correct unmarshal target - if len(bytes) > 0 && bytes[0] == '{' { - // JSON object - unmarshal into map - var unmarshaled map[string]interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } else { - // JSON primitive (string, number, boolean, null) - unmarshal into interface{} - var unmarshaled interface{} - err = json.Unmarshal(bytes, &unmarshaled) - require.NoError(t, err, "unmarshaling should succeed for test verification") - } + // Unmarshal back and verify round-trip + var unmarshaled DetailedOrg + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err, "round-trip unmarshal should succeed") + }) - // Note: This does not explicitly assert the presence of a specific JSON field - // It verifies that setting a field via setter allows successful JSON round-trip + t.Run("UnmarshalInvalidJSON", func(t *testing.T) { + t.Parallel() + var obj DetailedOrg + err := json.Unmarshal([]byte(`{invalid json}`), &obj) + assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) + t.Run("UnmarshalEmptyObject", func(t *testing.T) { + t.Parallel() + var obj DetailedOrg + err := json.Unmarshal([]byte(`{}`), &obj) + assert.NoError(t, err, "unmarshaling empty object should succeed") + }) } -func TestJSONMarshalingAuditInfo(t *testing.T) { +func TestJSONMarshalingDetailedOrgMetadata(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &AuditInfo{} + obj := &DetailedOrgMetadata{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3375,31 +5419,31 @@ func TestJSONMarshalingAuditInfo(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled AuditInfo + var unmarshaled DetailedOrgMetadata err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj AuditInfo + var obj DetailedOrgMetadata err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj AuditInfo + var obj DetailedOrgMetadata err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingBaseOrg(t *testing.T) { +func TestJSONMarshalingIdentifiable(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &BaseOrg{} + obj := &Identifiable{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3408,31 +5452,31 @@ func TestJSONMarshalingBaseOrg(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled BaseOrg + var unmarshaled Identifiable err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj BaseOrg + var obj Identifiable err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj BaseOrg + var obj Identifiable err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingBaseOrgMetadata(t *testing.T) { +func TestJSONMarshalingOrganization(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &BaseOrgMetadata{} + obj := &Organization{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3441,31 +5485,31 @@ func TestJSONMarshalingBaseOrgMetadata(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled BaseOrgMetadata + var unmarshaled Organization err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj BaseOrgMetadata + var obj Organization err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj BaseOrgMetadata + var obj Organization err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingCombinedEntity(t *testing.T) { +func TestJSONMarshalingOrganizationMetadata(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &CombinedEntity{} + obj := &OrganizationMetadata{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3474,31 +5518,31 @@ func TestJSONMarshalingCombinedEntity(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled CombinedEntity + var unmarshaled OrganizationMetadata err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj CombinedEntity + var obj OrganizationMetadata err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj CombinedEntity + var obj OrganizationMetadata err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingDescribable(t *testing.T) { +func TestJSONMarshalingPaginatedResult(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &Describable{} + obj := &PaginatedResult{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3507,31 +5551,31 @@ func TestJSONMarshalingDescribable(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled Describable + var unmarshaled PaginatedResult err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj Describable + var obj PaginatedResult err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj Describable + var obj PaginatedResult err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingDetailedOrg(t *testing.T) { +func TestJSONMarshalingPagingCursors(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &DetailedOrg{} + obj := &PagingCursors{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3540,31 +5584,31 @@ func TestJSONMarshalingDetailedOrg(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled DetailedOrg + var unmarshaled PagingCursors err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj DetailedOrg + var obj PagingCursors err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj DetailedOrg + var obj PagingCursors err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingDetailedOrgMetadata(t *testing.T) { +func TestJSONMarshalingPlantBase(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &DetailedOrgMetadata{} + obj := &PlantBase{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3573,31 +5617,31 @@ func TestJSONMarshalingDetailedOrgMetadata(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled DetailedOrgMetadata + var unmarshaled PlantBase err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj DetailedOrgMetadata + var obj PlantBase err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj DetailedOrgMetadata + var obj PlantBase err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingIdentifiable(t *testing.T) { +func TestJSONMarshalingPlantStrict(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &Identifiable{} + obj := &PlantStrict{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3606,31 +5650,31 @@ func TestJSONMarshalingIdentifiable(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled Identifiable + var unmarshaled PlantStrict err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj Identifiable + var obj PlantStrict err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj Identifiable + var obj PlantStrict err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingOrganization(t *testing.T) { +func TestJSONMarshalingRuleResponse(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &Organization{} + obj := &RuleResponse{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3639,31 +5683,31 @@ func TestJSONMarshalingOrganization(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled Organization + var unmarshaled RuleResponse err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj Organization + var obj RuleResponse err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj Organization + var obj RuleResponse err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingOrganizationMetadata(t *testing.T) { +func TestJSONMarshalingRuleType(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &OrganizationMetadata{} + obj := &RuleType{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3672,31 +5716,31 @@ func TestJSONMarshalingOrganizationMetadata(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled OrganizationMetadata + var unmarshaled RuleType err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj OrganizationMetadata + var obj RuleType err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj OrganizationMetadata + var obj RuleType err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingPaginatedResult(t *testing.T) { +func TestJSONMarshalingRuleTypeSearchResponse(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &PaginatedResult{} + obj := &RuleTypeSearchResponse{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3705,31 +5749,31 @@ func TestJSONMarshalingPaginatedResult(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled PaginatedResult + var unmarshaled RuleTypeSearchResponse err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj PaginatedResult + var obj RuleTypeSearchResponse err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj PaginatedResult + var obj RuleTypeSearchResponse err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingPagingCursors(t *testing.T) { +func TestJSONMarshalingTreeBase(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &PagingCursors{} + obj := &TreeBase{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3738,31 +5782,31 @@ func TestJSONMarshalingPagingCursors(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled PagingCursors + var unmarshaled TreeBase err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj PagingCursors + var obj TreeBase err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj PagingCursors + var obj TreeBase err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingRuleResponse(t *testing.T) { +func TestJSONMarshalingTreeDescribable(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleResponse{} + obj := &TreeDescribable{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3771,31 +5815,31 @@ func TestJSONMarshalingRuleResponse(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled RuleResponse + var unmarshaled TreeDescribable err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj RuleResponse + var obj TreeDescribable err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj RuleResponse + var obj TreeDescribable err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingRuleType(t *testing.T) { +func TestJSONMarshalingTreeIdentifiable(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleType{} + obj := &TreeIdentifiable{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3804,31 +5848,31 @@ func TestJSONMarshalingRuleType(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled RuleType + var unmarshaled TreeIdentifiable err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj RuleType + var obj TreeIdentifiable err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj RuleType + var obj TreeIdentifiable err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) } -func TestJSONMarshalingRuleTypeSearchResponse(t *testing.T) { +func TestJSONMarshalingTreeRecord(t *testing.T) { t.Run("MarshalUnmarshal", func(t *testing.T) { t.Parallel() // Arrange - obj := &RuleTypeSearchResponse{} + obj := &TreeRecord{} // Act - Marshal to JSON data, err := json.Marshal(obj) @@ -3837,21 +5881,21 @@ func TestJSONMarshalingRuleTypeSearchResponse(t *testing.T) { assert.NotEmpty(t, data, "marshaled data should not be empty") // Unmarshal back and verify round-trip - var unmarshaled RuleTypeSearchResponse + var unmarshaled TreeRecord err = json.Unmarshal(data, &unmarshaled) assert.NoError(t, err, "round-trip unmarshal should succeed") }) t.Run("UnmarshalInvalidJSON", func(t *testing.T) { t.Parallel() - var obj RuleTypeSearchResponse + var obj TreeRecord err := json.Unmarshal([]byte(`{invalid json}`), &obj) assert.Error(t, err, "unmarshaling invalid JSON should return an error") }) t.Run("UnmarshalEmptyObject", func(t *testing.T) { t.Parallel() - var obj RuleTypeSearchResponse + var obj TreeRecord err := json.Unmarshal([]byte(`{}`), &obj) assert.NoError(t, err, "unmarshaling empty object should succeed") }) @@ -3965,199 +6009,295 @@ func TestStringBaseOrgMetadata(t *testing.T) { t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *BaseOrgMetadata + var obj *BaseOrgMetadata + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + +func TestStringCombinedEntity(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &CombinedEntity{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *CombinedEntity + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + +func TestStringDescribable(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &Describable{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *Describable + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + +func TestStringDetailedOrg(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &DetailedOrg{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *DetailedOrg + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + +func TestStringDetailedOrgMetadata(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &DetailedOrgMetadata{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *DetailedOrgMetadata + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + +func TestStringIdentifiable(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &Identifiable{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *Identifiable + result := obj.String() + assert.Equal(t, "", result, "String() should return for nil receiver") + }) +} + +func TestStringOrganization(t *testing.T) { + t.Run("StringMethod", func(t *testing.T) { + t.Parallel() + obj := &Organization{} + result := obj.String() + assert.NotEmpty(t, result, "String() should return a non-empty representation") + }) + + t.Run("StringMethod_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *Organization result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringCombinedEntity(t *testing.T) { +func TestStringOrganizationMetadata(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &CombinedEntity{} + obj := &OrganizationMetadata{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *CombinedEntity + var obj *OrganizationMetadata result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringDescribable(t *testing.T) { +func TestStringPaginatedResult(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &Describable{} + obj := &PaginatedResult{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *Describable + var obj *PaginatedResult result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringDetailedOrg(t *testing.T) { +func TestStringPagingCursors(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &DetailedOrg{} + obj := &PagingCursors{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *DetailedOrg + var obj *PagingCursors result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringDetailedOrgMetadata(t *testing.T) { +func TestStringPlantBase(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &DetailedOrgMetadata{} + obj := &PlantBase{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *DetailedOrgMetadata + var obj *PlantBase result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringIdentifiable(t *testing.T) { +func TestStringPlantStrict(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &Identifiable{} + obj := &PlantStrict{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *Identifiable + var obj *PlantStrict result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringOrganization(t *testing.T) { +func TestStringRuleResponse(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &Organization{} + obj := &RuleResponse{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *Organization + var obj *RuleResponse result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringOrganizationMetadata(t *testing.T) { +func TestStringRuleType(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &OrganizationMetadata{} + obj := &RuleType{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *OrganizationMetadata + var obj *RuleType result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringPaginatedResult(t *testing.T) { +func TestStringRuleTypeSearchResponse(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &PaginatedResult{} + obj := &RuleTypeSearchResponse{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *PaginatedResult + var obj *RuleTypeSearchResponse result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringPagingCursors(t *testing.T) { +func TestStringTreeBase(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &PagingCursors{} + obj := &TreeBase{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *PagingCursors + var obj *TreeBase result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringRuleResponse(t *testing.T) { +func TestStringTreeDescribable(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &RuleResponse{} + obj := &TreeDescribable{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleResponse + var obj *TreeDescribable result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringRuleType(t *testing.T) { +func TestStringTreeIdentifiable(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &RuleType{} + obj := &TreeIdentifiable{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleType + var obj *TreeIdentifiable result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) } -func TestStringRuleTypeSearchResponse(t *testing.T) { +func TestStringTreeRecord(t *testing.T) { t.Run("StringMethod", func(t *testing.T) { t.Parallel() - obj := &RuleTypeSearchResponse{} + obj := &TreeRecord{} result := obj.String() assert.NotEmpty(t, result, "String() should return a non-empty representation") }) t.Run("StringMethod_NilReceiver", func(t *testing.T) { t.Parallel() - var obj *RuleTypeSearchResponse + var obj *TreeRecord result := obj.String() assert.Equal(t, "", result, "String() should return for nil receiver") }) @@ -4224,6 +6364,128 @@ func TestEnumCombinedEntityStatus(t *testing.T) { }) } +func TestEnumPlantBaseWateringFrequency(t *testing.T) { + t.Run("NewFromString_daily", func(t *testing.T) { + t.Parallel() + val, err := NewPlantBaseWateringFrequencyFromString("daily") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantBaseWateringFrequency("daily"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_weekly", func(t *testing.T) { + t.Parallel() + val, err := NewPlantBaseWateringFrequencyFromString("weekly") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantBaseWateringFrequency("weekly"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_biweekly", func(t *testing.T) { + t.Parallel() + val, err := NewPlantBaseWateringFrequencyFromString("biweekly") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantBaseWateringFrequency("biweekly"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_monthly", func(t *testing.T) { + t.Parallel() + val, err := NewPlantBaseWateringFrequencyFromString("monthly") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantBaseWateringFrequency("monthly"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_Invalid", func(t *testing.T) { + _, err := NewPlantBaseWateringFrequencyFromString("invalid_value_that_does_not_exist") + assert.Error(t, err) + }) + + t.Run("Ptr", func(t *testing.T) { + val, err := NewPlantBaseWateringFrequencyFromString("daily") + assert.NoError(t, err) + ptr := val.Ptr() + assert.NotNil(t, ptr) + assert.Equal(t, val, *ptr) + }) +} + +func TestEnumPlantPostSunExposure(t *testing.T) { + t.Run("NewFromString_full", func(t *testing.T) { + t.Parallel() + val, err := NewPlantPostSunExposureFromString("full") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantPostSunExposure("full"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_partial", func(t *testing.T) { + t.Parallel() + val, err := NewPlantPostSunExposureFromString("partial") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantPostSunExposure("partial"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_shade", func(t *testing.T) { + t.Parallel() + val, err := NewPlantPostSunExposureFromString("shade") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantPostSunExposure("shade"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_Invalid", func(t *testing.T) { + _, err := NewPlantPostSunExposureFromString("invalid_value_that_does_not_exist") + assert.Error(t, err) + }) + + t.Run("Ptr", func(t *testing.T) { + val, err := NewPlantPostSunExposureFromString("full") + assert.NoError(t, err) + ptr := val.Ptr() + assert.NotNil(t, ptr) + assert.Equal(t, val, *ptr) + }) +} + +func TestEnumPlantPostWateringFrequency(t *testing.T) { + t.Run("NewFromString_daily", func(t *testing.T) { + t.Parallel() + val, err := NewPlantPostWateringFrequencyFromString("daily") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantPostWateringFrequency("daily"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_weekly", func(t *testing.T) { + t.Parallel() + val, err := NewPlantPostWateringFrequencyFromString("weekly") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantPostWateringFrequency("weekly"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_biweekly", func(t *testing.T) { + t.Parallel() + val, err := NewPlantPostWateringFrequencyFromString("biweekly") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantPostWateringFrequency("biweekly"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_monthly", func(t *testing.T) { + t.Parallel() + val, err := NewPlantPostWateringFrequencyFromString("monthly") + assert.NoError(t, err, "valid enum value should not return error") + assert.Equal(t, PlantPostWateringFrequency("monthly"), val, "enum value should match expected wire value") + }) + + t.Run("NewFromString_Invalid", func(t *testing.T) { + _, err := NewPlantPostWateringFrequencyFromString("invalid_value_that_does_not_exist") + assert.Error(t, err) + }) + + t.Run("Ptr", func(t *testing.T) { + val, err := NewPlantPostWateringFrequencyFromString("daily") + assert.NoError(t, err) + ptr := val.Ptr() + assert.NotNil(t, ptr) + assert.Equal(t, val, *ptr) + }) +} + func TestEnumRuleCreateRequestExecutionContext(t *testing.T) { t.Run("NewFromString_prod", func(t *testing.T) { t.Parallel() @@ -4608,6 +6870,52 @@ func TestExtraPropertiesPagingCursors(t *testing.T) { }) } +func TestExtraPropertiesPlantBase(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &PlantBase{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *PlantBase + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + +func TestExtraPropertiesPlantStrict(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &PlantStrict{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *PlantStrict + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + func TestExtraPropertiesRuleResponse(t *testing.T) { t.Run("GetExtraProperties", func(t *testing.T) { t.Parallel() @@ -4677,6 +6985,98 @@ func TestExtraPropertiesRuleTypeSearchResponse(t *testing.T) { }) } +func TestExtraPropertiesTreeBase(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &TreeBase{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeBase + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + +func TestExtraPropertiesTreeDescribable(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &TreeDescribable{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeDescribable + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + +func TestExtraPropertiesTreeIdentifiable(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &TreeIdentifiable{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeIdentifiable + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + +func TestExtraPropertiesTreeRecord(t *testing.T) { + t.Run("GetExtraProperties", func(t *testing.T) { + t.Parallel() + obj := &TreeRecord{} + // Should not panic when calling GetExtraProperties() + defer func() { + if r := recover(); r != nil { + t.Errorf("GetExtraProperties() panicked: %v", r) + } + }() + extraProps := obj.GetExtraProperties() + // Result can be nil or an empty/non-empty map + _ = extraProps + }) + + t.Run("GetExtraProperties_NilReceiver", func(t *testing.T) { + t.Parallel() + var obj *TreeRecord + extraProps := obj.GetExtraProperties() + assert.Nil(t, extraProps, "nil receiver should return nil without panicking") + }) +} + func TestExtraPropertiesUser(t *testing.T) { t.Run("GetExtraProperties", func(t *testing.T) { t.Parallel() diff --git a/seed/go-sdk/allof/.fern/metadata.json b/seed/go-sdk/allof/.fern/metadata.json index 5b82336b7636..4325dc2a3860 100644 --- a/seed/go-sdk/allof/.fern/metadata.json +++ b/seed/go-sdk/allof/.fern/metadata.json @@ -6,7 +6,8 @@ "enableWireTests": false }, "originGitCommit": "DUMMY", - "invokedBy": "manual", + "invokedBy": "ci", "requestedVersion": "0.0.1", + "ciProvider": "github", "sdkVersion": "v0.0.1" } \ No newline at end of file diff --git a/seed/java-sdk/allof-inline/reference.md b/seed/java-sdk/allof-inline/reference.md index 3ffb31766886..63ce6f94ff87 100644 --- a/seed/java-sdk/allof-inline/reference.md +++ b/seed/java-sdk/allof-inline/reference.md @@ -172,3 +172,184 @@ client.getOrganization(); +
client.createPlant(request) -> PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```java +client.createPlant( + PlantPost + .builder() + .species("species") + .family("family") + .genus("genus") + .commonName("commonName") + .wateringFrequency(PlantPostWateringFrequency.DAILY) + .sunExposure(PlantPostSunExposure.FULL) + .build() +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**species:** `String` — The botanical species name. + +
+
+ +
+
+ +**family:** `String` — The botanical family. + +
+
+ +
+
+ +**genus:** `String` — The botanical genus. + +
+
+ +
+
+ +**commonName:** `String` — The common name of the plant. + +
+
+ +
+
+ +**wateringFrequency:** `PlantPostWateringFrequency` + +
+
+ +
+
+ +**sunExposure:** `PlantPostSunExposure` — Required sun exposure level. + +
+
+ +
+
+ +**plantedAt:** `Optional` — Date the plant was planted. + +
+
+ +
+
+ +**soilType:** `Optional` — Preferred soil type. + +
+
+
+
+ + +
+
+
+ +
client.createTree(request) -> TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```java +client.createTree( + TreeRecord + .builder() + .id("id") + .treeName("treeName") + .treeSpecies("treeSpecies") + .build() +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TreeRecord` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/java-sdk/allof-inline/snippet.json b/seed/java-sdk/allof-inline/snippet.json index affeed349f90..7256bbbc6c57 100644 --- a/seed/java-sdk/allof-inline/snippet.json +++ b/seed/java-sdk/allof-inline/snippet.json @@ -64,6 +64,32 @@ "sync_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.getOrganization();\n }\n}\n", "async_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.getOrganization();\n }\n}\n" } + }, + { + "example_identifier": "2138ee95", + "id": { + "method": "POST", + "path": "/plants", + "identifier_override": "endpoint_.createPlant" + }, + "snippet": { + "type": "java", + "sync_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\nimport com.seed.api.requests.PlantPost;\nimport com.seed.api.types.PlantPostSunExposure;\nimport com.seed.api.types.PlantPostWateringFrequency;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.createPlant(\n PlantPost\n .builder()\n .species(\"species\")\n .family(\"family\")\n .genus(\"genus\")\n .commonName(\"commonName\")\n .wateringFrequency(PlantPostWateringFrequency.DAILY)\n .sunExposure(PlantPostSunExposure.FULL)\n .build()\n );\n }\n}\n", + "async_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\nimport com.seed.api.requests.PlantPost;\nimport com.seed.api.types.PlantPostSunExposure;\nimport com.seed.api.types.PlantPostWateringFrequency;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.createPlant(\n PlantPost\n .builder()\n .species(\"species\")\n .family(\"family\")\n .genus(\"genus\")\n .commonName(\"commonName\")\n .wateringFrequency(PlantPostWateringFrequency.DAILY)\n .sunExposure(PlantPostSunExposure.FULL)\n .build()\n );\n }\n}\n" + } + }, + { + "example_identifier": "553352f7", + "id": { + "method": "POST", + "path": "/trees", + "identifier_override": "endpoint_.createTree" + }, + "snippet": { + "type": "java", + "sync_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\nimport com.seed.api.types.TreeRecord;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.createTree(\n TreeRecord\n .builder()\n .id(\"id\")\n .treeName(\"treeName\")\n .treeSpecies(\"treeSpecies\")\n .build()\n );\n }\n}\n", + "async_client": "package com.example.usage;\n\nimport com.seed.api.SeedApiClient;\nimport com.seed.api.types.TreeRecord;\n\npublic class Example {\n public static void main(String[] args) {\n SeedApiClient client = SeedApiClient\n .builder()\n .build();\n\n client.createTree(\n TreeRecord\n .builder()\n .id(\"id\")\n .treeName(\"treeName\")\n .treeSpecies(\"treeSpecies\")\n .build()\n );\n }\n}\n" + } } ], "types": {} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/AsyncRawSeedApiClient.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/AsyncRawSeedApiClient.java index 046deb483a7f..c18818939fc1 100644 --- a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/AsyncRawSeedApiClient.java +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/AsyncRawSeedApiClient.java @@ -12,12 +12,15 @@ import com.seed.api.core.SeedApiApiException; import com.seed.api.core.SeedApiException; import com.seed.api.core.SeedApiHttpResponse; +import com.seed.api.requests.PlantPost; import com.seed.api.requests.RuleCreateRequest; import com.seed.api.requests.SearchRuleTypesRequest; import com.seed.api.types.CombinedEntity; import com.seed.api.types.Organization; +import com.seed.api.types.PlantStrict; import com.seed.api.types.RuleResponse; import com.seed.api.types.RuleTypeSearchResponse; +import com.seed.api.types.TreeRecord; import com.seed.api.types.UserSearchResponse; import java.io.IOException; import java.util.concurrent.CompletableFuture; @@ -320,4 +323,136 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { }); return future; } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public CompletableFuture> createPlant(PlantPost request) { + return createPlant(request, null); + } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public CompletableFuture> createPlant( + PlantPost request, RequestOptions requestOptions) { + HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) + .newBuilder() + .addPathSegments("plants"); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((_key, _value) -> { + httpUrl.addQueryParameter(_key, _value); + }); + } + RequestBody body; + try { + body = RequestBody.create( + ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + } catch (JsonProcessingException e) { + throw new SeedApiException("Failed to serialize request", e); + } + Request okhttpRequest = new Request.Builder() + .url(httpUrl.build()) + .method("POST", body) + .headers(Headers.of(clientOptions.headers(requestOptions))) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .build(); + OkHttpClient client = clientOptions.httpClient(); + if (requestOptions != null && requestOptions.getTimeout().isPresent()) { + client = clientOptions.httpClientWithTimeout(requestOptions); + } + CompletableFuture> future = new CompletableFuture<>(); + client.newCall(okhttpRequest).enqueue(new Callback() { + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { + try (ResponseBody responseBody = response.body()) { + String responseBodyString = responseBody != null ? responseBody.string() : "{}"; + if (response.isSuccessful()) { + future.complete(new SeedApiHttpResponse<>( + ObjectMappers.JSON_MAPPER.readValue(responseBodyString, PlantStrict.class), response)); + return; + } + Object errorBody = ObjectMappers.parseErrorBody(responseBodyString); + future.completeExceptionally(new SeedApiApiException( + "Error with status code " + response.code(), response.code(), errorBody, response)); + return; + } catch (IOException e) { + future.completeExceptionally(new SeedApiException("Network error executing HTTP request", e)); + } + } + + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) { + future.completeExceptionally(new SeedApiException("Network error executing HTTP request", e)); + } + }); + return future; + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public CompletableFuture> createTree(TreeRecord request) { + return createTree(request, null); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public CompletableFuture> createTree( + TreeRecord request, RequestOptions requestOptions) { + HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) + .newBuilder() + .addPathSegments("trees"); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((_key, _value) -> { + httpUrl.addQueryParameter(_key, _value); + }); + } + RequestBody body; + try { + body = RequestBody.create( + ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + } catch (JsonProcessingException e) { + throw new SeedApiException("Failed to serialize request", e); + } + Request okhttpRequest = new Request.Builder() + .url(httpUrl.build()) + .method("POST", body) + .headers(Headers.of(clientOptions.headers(requestOptions))) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .build(); + OkHttpClient client = clientOptions.httpClient(); + if (requestOptions != null && requestOptions.getTimeout().isPresent()) { + client = clientOptions.httpClientWithTimeout(requestOptions); + } + CompletableFuture> future = new CompletableFuture<>(); + client.newCall(okhttpRequest).enqueue(new Callback() { + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { + try (ResponseBody responseBody = response.body()) { + String responseBodyString = responseBody != null ? responseBody.string() : "{}"; + if (response.isSuccessful()) { + future.complete(new SeedApiHttpResponse<>( + ObjectMappers.JSON_MAPPER.readValue(responseBodyString, TreeRecord.class), response)); + return; + } + Object errorBody = ObjectMappers.parseErrorBody(responseBodyString); + future.completeExceptionally(new SeedApiApiException( + "Error with status code " + response.code(), response.code(), errorBody, response)); + return; + } catch (IOException e) { + future.completeExceptionally(new SeedApiException("Network error executing HTTP request", e)); + } + } + + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) { + future.completeExceptionally(new SeedApiException("Network error executing HTTP request", e)); + } + }); + return future; + } } diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/AsyncSeedApiClient.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/AsyncSeedApiClient.java index 45b759db04ed..20e5132175ef 100644 --- a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/AsyncSeedApiClient.java +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/AsyncSeedApiClient.java @@ -5,12 +5,15 @@ import com.seed.api.core.ClientOptions; import com.seed.api.core.RequestOptions; +import com.seed.api.requests.PlantPost; import com.seed.api.requests.RuleCreateRequest; import com.seed.api.requests.SearchRuleTypesRequest; import com.seed.api.types.CombinedEntity; import com.seed.api.types.Organization; +import com.seed.api.types.PlantStrict; import com.seed.api.types.RuleResponse; import com.seed.api.types.RuleTypeSearchResponse; +import com.seed.api.types.TreeRecord; import com.seed.api.types.UserSearchResponse; import java.util.concurrent.CompletableFuture; @@ -80,6 +83,34 @@ public CompletableFuture getOrganization(RequestOptions requestOpt return this.rawClient.getOrganization(requestOptions).thenApply(response -> response.body()); } + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public CompletableFuture createPlant(PlantPost request) { + return this.rawClient.createPlant(request).thenApply(response -> response.body()); + } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public CompletableFuture createPlant(PlantPost request, RequestOptions requestOptions) { + return this.rawClient.createPlant(request, requestOptions).thenApply(response -> response.body()); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public CompletableFuture createTree(TreeRecord request) { + return this.rawClient.createTree(request).thenApply(response -> response.body()); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public CompletableFuture createTree(TreeRecord request, RequestOptions requestOptions) { + return this.rawClient.createTree(request, requestOptions).thenApply(response -> response.body()); + } + public static AsyncSeedApiClientBuilder builder() { return new AsyncSeedApiClientBuilder(); } diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/RawSeedApiClient.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/RawSeedApiClient.java index f9a763a744c4..c98320c38ccf 100644 --- a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/RawSeedApiClient.java +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/RawSeedApiClient.java @@ -12,12 +12,15 @@ import com.seed.api.core.SeedApiApiException; import com.seed.api.core.SeedApiException; import com.seed.api.core.SeedApiHttpResponse; +import com.seed.api.requests.PlantPost; import com.seed.api.requests.RuleCreateRequest; import com.seed.api.requests.SearchRuleTypesRequest; import com.seed.api.types.CombinedEntity; import com.seed.api.types.Organization; +import com.seed.api.types.PlantStrict; import com.seed.api.types.RuleResponse; import com.seed.api.types.RuleTypeSearchResponse; +import com.seed.api.types.TreeRecord; import com.seed.api.types.UserSearchResponse; import java.io.IOException; import okhttp3.Headers; @@ -246,4 +249,108 @@ public SeedApiHttpResponse getOrganization(RequestOptions requestO throw new SeedApiException("Network error executing HTTP request", e); } } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public SeedApiHttpResponse createPlant(PlantPost request) { + return createPlant(request, null); + } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public SeedApiHttpResponse createPlant(PlantPost request, RequestOptions requestOptions) { + HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) + .newBuilder() + .addPathSegments("plants"); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((_key, _value) -> { + httpUrl.addQueryParameter(_key, _value); + }); + } + RequestBody body; + try { + body = RequestBody.create( + ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + } catch (JsonProcessingException e) { + throw new SeedApiException("Failed to serialize request", e); + } + Request okhttpRequest = new Request.Builder() + .url(httpUrl.build()) + .method("POST", body) + .headers(Headers.of(clientOptions.headers(requestOptions))) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .build(); + OkHttpClient client = clientOptions.httpClient(); + if (requestOptions != null && requestOptions.getTimeout().isPresent()) { + client = clientOptions.httpClientWithTimeout(requestOptions); + } + try (Response response = client.newCall(okhttpRequest).execute()) { + ResponseBody responseBody = response.body(); + String responseBodyString = responseBody != null ? responseBody.string() : "{}"; + if (response.isSuccessful()) { + return new SeedApiHttpResponse<>( + ObjectMappers.JSON_MAPPER.readValue(responseBodyString, PlantStrict.class), response); + } + Object errorBody = ObjectMappers.parseErrorBody(responseBodyString); + throw new SeedApiApiException( + "Error with status code " + response.code(), response.code(), errorBody, response); + } catch (IOException e) { + throw new SeedApiException("Network error executing HTTP request", e); + } + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public SeedApiHttpResponse createTree(TreeRecord request) { + return createTree(request, null); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public SeedApiHttpResponse createTree(TreeRecord request, RequestOptions requestOptions) { + HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) + .newBuilder() + .addPathSegments("trees"); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((_key, _value) -> { + httpUrl.addQueryParameter(_key, _value); + }); + } + RequestBody body; + try { + body = RequestBody.create( + ObjectMappers.JSON_MAPPER.writeValueAsBytes(request), MediaTypes.APPLICATION_JSON); + } catch (JsonProcessingException e) { + throw new SeedApiException("Failed to serialize request", e); + } + Request okhttpRequest = new Request.Builder() + .url(httpUrl.build()) + .method("POST", body) + .headers(Headers.of(clientOptions.headers(requestOptions))) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .build(); + OkHttpClient client = clientOptions.httpClient(); + if (requestOptions != null && requestOptions.getTimeout().isPresent()) { + client = clientOptions.httpClientWithTimeout(requestOptions); + } + try (Response response = client.newCall(okhttpRequest).execute()) { + ResponseBody responseBody = response.body(); + String responseBodyString = responseBody != null ? responseBody.string() : "{}"; + if (response.isSuccessful()) { + return new SeedApiHttpResponse<>( + ObjectMappers.JSON_MAPPER.readValue(responseBodyString, TreeRecord.class), response); + } + Object errorBody = ObjectMappers.parseErrorBody(responseBodyString); + throw new SeedApiApiException( + "Error with status code " + response.code(), response.code(), errorBody, response); + } catch (IOException e) { + throw new SeedApiException("Network error executing HTTP request", e); + } + } } diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/SeedApiClient.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/SeedApiClient.java index 73a73c0a87a2..cb5ec6826828 100644 --- a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/SeedApiClient.java +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/SeedApiClient.java @@ -5,12 +5,15 @@ import com.seed.api.core.ClientOptions; import com.seed.api.core.RequestOptions; +import com.seed.api.requests.PlantPost; import com.seed.api.requests.RuleCreateRequest; import com.seed.api.requests.SearchRuleTypesRequest; import com.seed.api.types.CombinedEntity; import com.seed.api.types.Organization; +import com.seed.api.types.PlantStrict; import com.seed.api.types.RuleResponse; import com.seed.api.types.RuleTypeSearchResponse; +import com.seed.api.types.TreeRecord; import com.seed.api.types.UserSearchResponse; public class SeedApiClient { @@ -78,6 +81,34 @@ public Organization getOrganization(RequestOptions requestOptions) { return this.rawClient.getOrganization(requestOptions).body(); } + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public PlantStrict createPlant(PlantPost request) { + return this.rawClient.createPlant(request).body(); + } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + */ + public PlantStrict createPlant(PlantPost request, RequestOptions requestOptions) { + return this.rawClient.createPlant(request, requestOptions).body(); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public TreeRecord createTree(TreeRecord request) { + return this.rawClient.createTree(request).body(); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + */ + public TreeRecord createTree(TreeRecord request, RequestOptions requestOptions) { + return this.rawClient.createTree(request, requestOptions).body(); + } + public static SeedApiClientBuilder builder() { return new SeedApiClientBuilder(); } diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/requests/PlantPost.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/requests/PlantPost.java new file mode 100644 index 000000000000..08a68242d71f --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/requests/PlantPost.java @@ -0,0 +1,409 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.requests; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import com.seed.api.types.PlantPostSunExposure; +import com.seed.api.types.PlantPostWateringFrequency; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = PlantPost.Builder.class) +public final class PlantPost { + private final String species; + + private final String family; + + private final String genus; + + private final String commonName; + + private final PlantPostWateringFrequency wateringFrequency; + + private final PlantPostSunExposure sunExposure; + + private final Optional plantedAt; + + private final Optional soilType; + + private final Map additionalProperties; + + private PlantPost( + String species, + String family, + String genus, + String commonName, + PlantPostWateringFrequency wateringFrequency, + PlantPostSunExposure sunExposure, + Optional plantedAt, + Optional soilType, + Map additionalProperties) { + this.species = species; + this.family = family; + this.genus = genus; + this.commonName = commonName; + this.wateringFrequency = wateringFrequency; + this.sunExposure = sunExposure; + this.plantedAt = plantedAt; + this.soilType = soilType; + this.additionalProperties = additionalProperties; + } + + /** + * @return The botanical species name. + */ + @JsonProperty("species") + public String getSpecies() { + return species; + } + + /** + * @return The botanical family. + */ + @JsonProperty("family") + public String getFamily() { + return family; + } + + /** + * @return The botanical genus. + */ + @JsonProperty("genus") + public String getGenus() { + return genus; + } + + /** + * @return The common name of the plant. + */ + @JsonProperty("commonName") + public String getCommonName() { + return commonName; + } + + @JsonProperty("wateringFrequency") + public PlantPostWateringFrequency getWateringFrequency() { + return wateringFrequency; + } + + /** + * @return Required sun exposure level. + */ + @JsonProperty("sunExposure") + public PlantPostSunExposure getSunExposure() { + return sunExposure; + } + + /** + * @return Date the plant was planted. + */ + @JsonProperty("plantedAt") + public Optional getPlantedAt() { + return plantedAt; + } + + /** + * @return Preferred soil type. + */ + @JsonProperty("soilType") + public Optional getSoilType() { + return soilType; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof PlantPost && equalTo((PlantPost) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(PlantPost other) { + return species.equals(other.species) + && family.equals(other.family) + && genus.equals(other.genus) + && commonName.equals(other.commonName) + && wateringFrequency.equals(other.wateringFrequency) + && sunExposure.equals(other.sunExposure) + && plantedAt.equals(other.plantedAt) + && soilType.equals(other.soilType); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.species, + this.family, + this.genus, + this.commonName, + this.wateringFrequency, + this.sunExposure, + this.plantedAt, + this.soilType); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static SpeciesStage builder() { + return new Builder(); + } + + public interface SpeciesStage { + /** + *

The botanical species name.

+ */ + FamilyStage species(@NotNull String species); + + Builder from(PlantPost other); + } + + public interface FamilyStage { + /** + *

The botanical family.

+ */ + GenusStage family(@NotNull String family); + } + + public interface GenusStage { + /** + *

The botanical genus.

+ */ + CommonNameStage genus(@NotNull String genus); + } + + public interface CommonNameStage { + /** + *

The common name of the plant.

+ */ + WateringFrequencyStage commonName(@NotNull String commonName); + } + + public interface WateringFrequencyStage { + SunExposureStage wateringFrequency(@NotNull PlantPostWateringFrequency wateringFrequency); + } + + public interface SunExposureStage { + /** + *

Required sun exposure level.

+ */ + _FinalStage sunExposure(@NotNull PlantPostSunExposure sunExposure); + } + + public interface _FinalStage { + PlantPost build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

Date the plant was planted.

+ */ + _FinalStage plantedAt(Optional plantedAt); + + _FinalStage plantedAt(String plantedAt); + + /** + *

Preferred soil type.

+ */ + _FinalStage soilType(Optional soilType); + + _FinalStage soilType(String soilType); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder + implements SpeciesStage, + FamilyStage, + GenusStage, + CommonNameStage, + WateringFrequencyStage, + SunExposureStage, + _FinalStage { + private String species; + + private String family; + + private String genus; + + private String commonName; + + private PlantPostWateringFrequency wateringFrequency; + + private PlantPostSunExposure sunExposure; + + private Optional soilType = Optional.empty(); + + private Optional plantedAt = Optional.empty(); + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(PlantPost other) { + species(other.getSpecies()); + family(other.getFamily()); + genus(other.getGenus()); + commonName(other.getCommonName()); + wateringFrequency(other.getWateringFrequency()); + sunExposure(other.getSunExposure()); + plantedAt(other.getPlantedAt()); + soilType(other.getSoilType()); + return this; + } + + /** + *

The botanical species name.

+ *

The botanical species name.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("species") + public FamilyStage species(@NotNull String species) { + this.species = Objects.requireNonNull(species, "species must not be null"); + return this; + } + + /** + *

The botanical family.

+ *

The botanical family.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("family") + public GenusStage family(@NotNull String family) { + this.family = Objects.requireNonNull(family, "family must not be null"); + return this; + } + + /** + *

The botanical genus.

+ *

The botanical genus.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("genus") + public CommonNameStage genus(@NotNull String genus) { + this.genus = Objects.requireNonNull(genus, "genus must not be null"); + return this; + } + + /** + *

The common name of the plant.

+ *

The common name of the plant.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("commonName") + public WateringFrequencyStage commonName(@NotNull String commonName) { + this.commonName = Objects.requireNonNull(commonName, "commonName must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("wateringFrequency") + public SunExposureStage wateringFrequency(@NotNull PlantPostWateringFrequency wateringFrequency) { + this.wateringFrequency = Objects.requireNonNull(wateringFrequency, "wateringFrequency must not be null"); + return this; + } + + /** + *

Required sun exposure level.

+ *

Required sun exposure level.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("sunExposure") + public _FinalStage sunExposure(@NotNull PlantPostSunExposure sunExposure) { + this.sunExposure = Objects.requireNonNull(sunExposure, "sunExposure must not be null"); + return this; + } + + /** + *

Preferred soil type.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage soilType(String soilType) { + this.soilType = Optional.ofNullable(soilType); + return this; + } + + /** + *

Preferred soil type.

+ */ + @java.lang.Override + @JsonSetter(value = "soilType", nulls = Nulls.SKIP) + public _FinalStage soilType(Optional soilType) { + this.soilType = soilType; + return this; + } + + /** + *

Date the plant was planted.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage plantedAt(String plantedAt) { + this.plantedAt = Optional.ofNullable(plantedAt); + return this; + } + + /** + *

Date the plant was planted.

+ */ + @java.lang.Override + @JsonSetter(value = "plantedAt", nulls = Nulls.SKIP) + public _FinalStage plantedAt(Optional plantedAt) { + this.plantedAt = plantedAt; + return this; + } + + @java.lang.Override + public PlantPost build() { + return new PlantPost( + species, + family, + genus, + commonName, + wateringFrequency, + sunExposure, + plantedAt, + soilType, + additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantBase.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantBase.java new file mode 100644 index 000000000000..362972fde728 --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantBase.java @@ -0,0 +1,276 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = PlantBase.Builder.class) +public final class PlantBase { + private final String species; + + private final String family; + + private final String genus; + + private final Optional commonName; + + private final Optional wateringFrequency; + + private final Map additionalProperties; + + private PlantBase( + String species, + String family, + String genus, + Optional commonName, + Optional wateringFrequency, + Map additionalProperties) { + this.species = species; + this.family = family; + this.genus = genus; + this.commonName = commonName; + this.wateringFrequency = wateringFrequency; + this.additionalProperties = additionalProperties; + } + + /** + * @return The botanical species name. + */ + @JsonProperty("species") + public String getSpecies() { + return species; + } + + /** + * @return The botanical family. + */ + @JsonProperty("family") + public String getFamily() { + return family; + } + + /** + * @return The botanical genus. + */ + @JsonProperty("genus") + public String getGenus() { + return genus; + } + + /** + * @return The common name of the plant. + */ + @JsonProperty("commonName") + public Optional getCommonName() { + return commonName; + } + + @JsonProperty("wateringFrequency") + public Optional getWateringFrequency() { + return wateringFrequency; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof PlantBase && equalTo((PlantBase) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(PlantBase other) { + return species.equals(other.species) + && family.equals(other.family) + && genus.equals(other.genus) + && commonName.equals(other.commonName) + && wateringFrequency.equals(other.wateringFrequency); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.species, this.family, this.genus, this.commonName, this.wateringFrequency); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static SpeciesStage builder() { + return new Builder(); + } + + public interface SpeciesStage { + /** + *

The botanical species name.

+ */ + FamilyStage species(@NotNull String species); + + Builder from(PlantBase other); + } + + public interface FamilyStage { + /** + *

The botanical family.

+ */ + GenusStage family(@NotNull String family); + } + + public interface GenusStage { + /** + *

The botanical genus.

+ */ + _FinalStage genus(@NotNull String genus); + } + + public interface _FinalStage { + PlantBase build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

The common name of the plant.

+ */ + _FinalStage commonName(Optional commonName); + + _FinalStage commonName(String commonName); + + _FinalStage wateringFrequency(Optional wateringFrequency); + + _FinalStage wateringFrequency(PlantBaseWateringFrequency wateringFrequency); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements SpeciesStage, FamilyStage, GenusStage, _FinalStage { + private String species; + + private String family; + + private String genus; + + private Optional wateringFrequency = Optional.empty(); + + private Optional commonName = Optional.empty(); + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(PlantBase other) { + species(other.getSpecies()); + family(other.getFamily()); + genus(other.getGenus()); + commonName(other.getCommonName()); + wateringFrequency(other.getWateringFrequency()); + return this; + } + + /** + *

The botanical species name.

+ *

The botanical species name.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("species") + public FamilyStage species(@NotNull String species) { + this.species = Objects.requireNonNull(species, "species must not be null"); + return this; + } + + /** + *

The botanical family.

+ *

The botanical family.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("family") + public GenusStage family(@NotNull String family) { + this.family = Objects.requireNonNull(family, "family must not be null"); + return this; + } + + /** + *

The botanical genus.

+ *

The botanical genus.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("genus") + public _FinalStage genus(@NotNull String genus) { + this.genus = Objects.requireNonNull(genus, "genus must not be null"); + return this; + } + + @java.lang.Override + public _FinalStage wateringFrequency(PlantBaseWateringFrequency wateringFrequency) { + this.wateringFrequency = Optional.ofNullable(wateringFrequency); + return this; + } + + @java.lang.Override + @JsonSetter(value = "wateringFrequency", nulls = Nulls.SKIP) + public _FinalStage wateringFrequency(Optional wateringFrequency) { + this.wateringFrequency = wateringFrequency; + return this; + } + + /** + *

The common name of the plant.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage commonName(String commonName) { + this.commonName = Optional.ofNullable(commonName); + return this; + } + + /** + *

The common name of the plant.

+ */ + @java.lang.Override + @JsonSetter(value = "commonName", nulls = Nulls.SKIP) + public _FinalStage commonName(Optional commonName) { + this.commonName = commonName; + return this; + } + + @java.lang.Override + public PlantBase build() { + return new PlantBase(species, family, genus, commonName, wateringFrequency, additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantBaseWateringFrequency.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantBaseWateringFrequency.java new file mode 100644 index 000000000000..e6556c10f9a1 --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantBaseWateringFrequency.java @@ -0,0 +1,105 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class PlantBaseWateringFrequency { + public static final PlantBaseWateringFrequency BIWEEKLY = + new PlantBaseWateringFrequency(Value.BIWEEKLY, "biweekly"); + + public static final PlantBaseWateringFrequency DAILY = new PlantBaseWateringFrequency(Value.DAILY, "daily"); + + public static final PlantBaseWateringFrequency WEEKLY = new PlantBaseWateringFrequency(Value.WEEKLY, "weekly"); + + public static final PlantBaseWateringFrequency MONTHLY = new PlantBaseWateringFrequency(Value.MONTHLY, "monthly"); + + private final Value value; + + private final String string; + + PlantBaseWateringFrequency(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof PlantBaseWateringFrequency + && this.string.equals(((PlantBaseWateringFrequency) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case BIWEEKLY: + return visitor.visitBiweekly(); + case DAILY: + return visitor.visitDaily(); + case WEEKLY: + return visitor.visitWeekly(); + case MONTHLY: + return visitor.visitMonthly(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static PlantBaseWateringFrequency valueOf(String value) { + switch (value) { + case "biweekly": + return BIWEEKLY; + case "daily": + return DAILY; + case "weekly": + return WEEKLY; + case "monthly": + return MONTHLY; + default: + return new PlantBaseWateringFrequency(Value.UNKNOWN, value); + } + } + + public enum Value { + DAILY, + + WEEKLY, + + BIWEEKLY, + + MONTHLY, + + UNKNOWN + } + + public interface Visitor { + T visitDaily(); + + T visitWeekly(); + + T visitBiweekly(); + + T visitMonthly(); + + T visitUnknown(String unknownType); + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantPostSunExposure.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantPostSunExposure.java new file mode 100644 index 000000000000..a9648170fa44 --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantPostSunExposure.java @@ -0,0 +1,93 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class PlantPostSunExposure { + public static final PlantPostSunExposure FULL = new PlantPostSunExposure(Value.FULL, "full"); + + public static final PlantPostSunExposure PARTIAL = new PlantPostSunExposure(Value.PARTIAL, "partial"); + + public static final PlantPostSunExposure SHADE = new PlantPostSunExposure(Value.SHADE, "shade"); + + private final Value value; + + private final String string; + + PlantPostSunExposure(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof PlantPostSunExposure && this.string.equals(((PlantPostSunExposure) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case FULL: + return visitor.visitFull(); + case PARTIAL: + return visitor.visitPartial(); + case SHADE: + return visitor.visitShade(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static PlantPostSunExposure valueOf(String value) { + switch (value) { + case "full": + return FULL; + case "partial": + return PARTIAL; + case "shade": + return SHADE; + default: + return new PlantPostSunExposure(Value.UNKNOWN, value); + } + } + + public enum Value { + FULL, + + PARTIAL, + + SHADE, + + UNKNOWN + } + + public interface Visitor { + T visitFull(); + + T visitPartial(); + + T visitShade(); + + T visitUnknown(String unknownType); + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantPostWateringFrequency.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantPostWateringFrequency.java new file mode 100644 index 000000000000..2283fc0c6ac0 --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantPostWateringFrequency.java @@ -0,0 +1,105 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class PlantPostWateringFrequency { + public static final PlantPostWateringFrequency BIWEEKLY = + new PlantPostWateringFrequency(Value.BIWEEKLY, "biweekly"); + + public static final PlantPostWateringFrequency DAILY = new PlantPostWateringFrequency(Value.DAILY, "daily"); + + public static final PlantPostWateringFrequency WEEKLY = new PlantPostWateringFrequency(Value.WEEKLY, "weekly"); + + public static final PlantPostWateringFrequency MONTHLY = new PlantPostWateringFrequency(Value.MONTHLY, "monthly"); + + private final Value value; + + private final String string; + + PlantPostWateringFrequency(Value value, String string) { + this.value = value; + this.string = string; + } + + public Value getEnumValue() { + return value; + } + + @java.lang.Override + @JsonValue + public String toString() { + return this.string; + } + + @java.lang.Override + public boolean equals(Object other) { + return (this == other) + || (other instanceof PlantPostWateringFrequency + && this.string.equals(((PlantPostWateringFrequency) other).string)); + } + + @java.lang.Override + public int hashCode() { + return this.string.hashCode(); + } + + public T visit(Visitor visitor) { + switch (value) { + case BIWEEKLY: + return visitor.visitBiweekly(); + case DAILY: + return visitor.visitDaily(); + case WEEKLY: + return visitor.visitWeekly(); + case MONTHLY: + return visitor.visitMonthly(); + case UNKNOWN: + default: + return visitor.visitUnknown(string); + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static PlantPostWateringFrequency valueOf(String value) { + switch (value) { + case "biweekly": + return BIWEEKLY; + case "daily": + return DAILY; + case "weekly": + return WEEKLY; + case "monthly": + return MONTHLY; + default: + return new PlantPostWateringFrequency(Value.UNKNOWN, value); + } + } + + public enum Value { + DAILY, + + WEEKLY, + + BIWEEKLY, + + MONTHLY, + + UNKNOWN + } + + public interface Visitor { + T visitDaily(); + + T visitWeekly(); + + T visitBiweekly(); + + T visitMonthly(); + + T visitUnknown(String unknownType); + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantStrict.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantStrict.java new file mode 100644 index 000000000000..920e40090ded --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/PlantStrict.java @@ -0,0 +1,195 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = PlantStrict.Builder.class) +public final class PlantStrict { + private final String species; + + private final String family; + + private final String genus; + + private final Map additionalProperties; + + private PlantStrict(String species, String family, String genus, Map additionalProperties) { + this.species = species; + this.family = family; + this.genus = genus; + this.additionalProperties = additionalProperties; + } + + /** + * @return The botanical species name. + */ + @JsonProperty("species") + public String getSpecies() { + return species; + } + + /** + * @return The botanical family. + */ + @JsonProperty("family") + public String getFamily() { + return family; + } + + /** + * @return The botanical genus. + */ + @JsonProperty("genus") + public String getGenus() { + return genus; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof PlantStrict && equalTo((PlantStrict) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(PlantStrict other) { + return species.equals(other.species) && family.equals(other.family) && genus.equals(other.genus); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.species, this.family, this.genus); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static SpeciesStage builder() { + return new Builder(); + } + + public interface SpeciesStage { + /** + *

The botanical species name.

+ */ + FamilyStage species(@NotNull String species); + + Builder from(PlantStrict other); + } + + public interface FamilyStage { + /** + *

The botanical family.

+ */ + GenusStage family(@NotNull String family); + } + + public interface GenusStage { + /** + *

The botanical genus.

+ */ + _FinalStage genus(@NotNull String genus); + } + + public interface _FinalStage { + PlantStrict build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements SpeciesStage, FamilyStage, GenusStage, _FinalStage { + private String species; + + private String family; + + private String genus; + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(PlantStrict other) { + species(other.getSpecies()); + family(other.getFamily()); + genus(other.getGenus()); + return this; + } + + /** + *

The botanical species name.

+ *

The botanical species name.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("species") + public FamilyStage species(@NotNull String species) { + this.species = Objects.requireNonNull(species, "species must not be null"); + return this; + } + + /** + *

The botanical family.

+ *

The botanical family.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("family") + public GenusStage family(@NotNull String family) { + this.family = Objects.requireNonNull(family, "family must not be null"); + return this; + } + + /** + *

The botanical genus.

+ *

The botanical genus.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("genus") + public _FinalStage genus(@NotNull String genus) { + this.genus = Objects.requireNonNull(genus, "genus must not be null"); + return this; + } + + @java.lang.Override + public PlantStrict build() { + return new PlantStrict(species, family, genus, additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeBase.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeBase.java new file mode 100644 index 000000000000..0cde6dac1547 --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeBase.java @@ -0,0 +1,305 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = TreeBase.Builder.class) +public final class TreeBase { + private final String id; + + private final Optional treeName; + + private final Optional treeDescription; + + private final Optional treeSpecies; + + private final Optional heightInFeet; + + private final Map additionalProperties; + + private TreeBase( + String id, + Optional treeName, + Optional treeDescription, + Optional treeSpecies, + Optional heightInFeet, + Map additionalProperties) { + this.id = id; + this.treeName = treeName; + this.treeDescription = treeDescription; + this.treeSpecies = treeSpecies; + this.heightInFeet = heightInFeet; + this.additionalProperties = additionalProperties; + } + + /** + * @return Unique tree identifier. + */ + @JsonProperty("id") + public String getId() { + return id; + } + + /** + * @return Display name of the tree. + */ + @JsonProperty("treeName") + public Optional getTreeName() { + return treeName; + } + + /** + * @return A description of the tree. + */ + @JsonProperty("treeDescription") + public Optional getTreeDescription() { + return treeDescription; + } + + /** + * @return The species of tree. + */ + @JsonProperty("treeSpecies") + public Optional getTreeSpecies() { + return treeSpecies; + } + + /** + * @return Height of the tree in feet. + */ + @JsonProperty("heightInFeet") + public Optional getHeightInFeet() { + return heightInFeet; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof TreeBase && equalTo((TreeBase) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(TreeBase other) { + return id.equals(other.id) + && treeName.equals(other.treeName) + && treeDescription.equals(other.treeDescription) + && treeSpecies.equals(other.treeSpecies) + && heightInFeet.equals(other.heightInFeet); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.id, this.treeName, this.treeDescription, this.treeSpecies, this.heightInFeet); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static IdStage builder() { + return new Builder(); + } + + public interface IdStage { + /** + *

Unique tree identifier.

+ */ + _FinalStage id(@NotNull String id); + + Builder from(TreeBase other); + } + + public interface _FinalStage { + TreeBase build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

Display name of the tree.

+ */ + _FinalStage treeName(Optional treeName); + + _FinalStage treeName(String treeName); + + /** + *

A description of the tree.

+ */ + _FinalStage treeDescription(Optional treeDescription); + + _FinalStage treeDescription(String treeDescription); + + /** + *

The species of tree.

+ */ + _FinalStage treeSpecies(Optional treeSpecies); + + _FinalStage treeSpecies(String treeSpecies); + + /** + *

Height of the tree in feet.

+ */ + _FinalStage heightInFeet(Optional heightInFeet); + + _FinalStage heightInFeet(Double heightInFeet); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements IdStage, _FinalStage { + private String id; + + private Optional heightInFeet = Optional.empty(); + + private Optional treeSpecies = Optional.empty(); + + private Optional treeDescription = Optional.empty(); + + private Optional treeName = Optional.empty(); + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(TreeBase other) { + id(other.getId()); + treeName(other.getTreeName()); + treeDescription(other.getTreeDescription()); + treeSpecies(other.getTreeSpecies()); + heightInFeet(other.getHeightInFeet()); + return this; + } + + /** + *

Unique tree identifier.

+ *

Unique tree identifier.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("id") + public _FinalStage id(@NotNull String id) { + this.id = Objects.requireNonNull(id, "id must not be null"); + return this; + } + + /** + *

Height of the tree in feet.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage heightInFeet(Double heightInFeet) { + this.heightInFeet = Optional.ofNullable(heightInFeet); + return this; + } + + /** + *

Height of the tree in feet.

+ */ + @java.lang.Override + @JsonSetter(value = "heightInFeet", nulls = Nulls.SKIP) + public _FinalStage heightInFeet(Optional heightInFeet) { + this.heightInFeet = heightInFeet; + return this; + } + + /** + *

The species of tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage treeSpecies(String treeSpecies) { + this.treeSpecies = Optional.ofNullable(treeSpecies); + return this; + } + + /** + *

The species of tree.

+ */ + @java.lang.Override + @JsonSetter(value = "treeSpecies", nulls = Nulls.SKIP) + public _FinalStage treeSpecies(Optional treeSpecies) { + this.treeSpecies = treeSpecies; + return this; + } + + /** + *

A description of the tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage treeDescription(String treeDescription) { + this.treeDescription = Optional.ofNullable(treeDescription); + return this; + } + + /** + *

A description of the tree.

+ */ + @java.lang.Override + @JsonSetter(value = "treeDescription", nulls = Nulls.SKIP) + public _FinalStage treeDescription(Optional treeDescription) { + this.treeDescription = treeDescription; + return this; + } + + /** + *

Display name of the tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage treeName(String treeName) { + this.treeName = Optional.ofNullable(treeName); + return this; + } + + /** + *

Display name of the tree.

+ */ + @java.lang.Override + @JsonSetter(value = "treeName", nulls = Nulls.SKIP) + public _FinalStage treeName(Optional treeName) { + this.treeName = treeName; + return this; + } + + @java.lang.Override + public TreeBase build() { + return new TreeBase(id, treeName, treeDescription, treeSpecies, heightInFeet, additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeDescribable.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeDescribable.java new file mode 100644 index 000000000000..3ef19e00f295 --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeDescribable.java @@ -0,0 +1,140 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = TreeDescribable.Builder.class) +public final class TreeDescribable { + private final Optional treeName; + + private final Optional treeDescription; + + private final Map additionalProperties; + + private TreeDescribable( + Optional treeName, Optional treeDescription, Map additionalProperties) { + this.treeName = treeName; + this.treeDescription = treeDescription; + this.additionalProperties = additionalProperties; + } + + /** + * @return Display name of the tree. + */ + @JsonProperty("treeName") + public Optional getTreeName() { + return treeName; + } + + /** + * @return A description of the tree. + */ + @JsonProperty("treeDescription") + public Optional getTreeDescription() { + return treeDescription; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof TreeDescribable && equalTo((TreeDescribable) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(TreeDescribable other) { + return treeName.equals(other.treeName) && treeDescription.equals(other.treeDescription); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.treeName, this.treeDescription); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static Builder builder() { + return new Builder(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder { + private Optional treeName = Optional.empty(); + + private Optional treeDescription = Optional.empty(); + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + public Builder from(TreeDescribable other) { + treeName(other.getTreeName()); + treeDescription(other.getTreeDescription()); + return this; + } + + /** + *

Display name of the tree.

+ */ + @JsonSetter(value = "treeName", nulls = Nulls.SKIP) + public Builder treeName(Optional treeName) { + this.treeName = treeName; + return this; + } + + public Builder treeName(String treeName) { + this.treeName = Optional.ofNullable(treeName); + return this; + } + + /** + *

A description of the tree.

+ */ + @JsonSetter(value = "treeDescription", nulls = Nulls.SKIP) + public Builder treeDescription(Optional treeDescription) { + this.treeDescription = treeDescription; + return this; + } + + public Builder treeDescription(String treeDescription) { + this.treeDescription = Optional.ofNullable(treeDescription); + return this; + } + + public TreeDescribable build() { + return new TreeDescribable(treeName, treeDescription, additionalProperties); + } + + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeIdentifiable.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeIdentifiable.java new file mode 100644 index 000000000000..5d00f1daf18b --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeIdentifiable.java @@ -0,0 +1,129 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = TreeIdentifiable.Builder.class) +public final class TreeIdentifiable { + private final String id; + + private final Map additionalProperties; + + private TreeIdentifiable(String id, Map additionalProperties) { + this.id = id; + this.additionalProperties = additionalProperties; + } + + /** + * @return Unique tree identifier. + */ + @JsonProperty("id") + public String getId() { + return id; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof TreeIdentifiable && equalTo((TreeIdentifiable) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(TreeIdentifiable other) { + return id.equals(other.id); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.id); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static IdStage builder() { + return new Builder(); + } + + public interface IdStage { + /** + *

Unique tree identifier.

+ */ + _FinalStage id(@NotNull String id); + + Builder from(TreeIdentifiable other); + } + + public interface _FinalStage { + TreeIdentifiable build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements IdStage, _FinalStage { + private String id; + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(TreeIdentifiable other) { + id(other.getId()); + return this; + } + + /** + *

Unique tree identifier.

+ *

Unique tree identifier.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("id") + public _FinalStage id(@NotNull String id) { + this.id = Objects.requireNonNull(id, "id must not be null"); + return this; + } + + @java.lang.Override + public TreeIdentifiable build() { + return new TreeIdentifiable(id, additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeRecord.java b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeRecord.java new file mode 100644 index 000000000000..a20920c69be1 --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/seed/api/types/TreeRecord.java @@ -0,0 +1,334 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +package com.seed.api.types; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.seed.api.core.ObjectMappers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = TreeRecord.Builder.class) +public final class TreeRecord { + private final String id; + + private final String treeName; + + private final Optional treeDescription; + + private final String treeSpecies; + + private final Optional heightInFeet; + + private final Optional plantedDate; + + private final Map additionalProperties; + + private TreeRecord( + String id, + String treeName, + Optional treeDescription, + String treeSpecies, + Optional heightInFeet, + Optional plantedDate, + Map additionalProperties) { + this.id = id; + this.treeName = treeName; + this.treeDescription = treeDescription; + this.treeSpecies = treeSpecies; + this.heightInFeet = heightInFeet; + this.plantedDate = plantedDate; + this.additionalProperties = additionalProperties; + } + + /** + * @return Unique tree identifier. + */ + @JsonProperty("id") + public String getId() { + return id; + } + + /** + * @return Display name of the tree. + */ + @JsonProperty("treeName") + public String getTreeName() { + return treeName; + } + + /** + * @return A description of the tree. + */ + @JsonProperty("treeDescription") + public Optional getTreeDescription() { + return treeDescription; + } + + /** + * @return The species of tree. + */ + @JsonProperty("treeSpecies") + public String getTreeSpecies() { + return treeSpecies; + } + + /** + * @return Height of the tree in feet. + */ + @JsonProperty("heightInFeet") + public Optional getHeightInFeet() { + return heightInFeet; + } + + /** + * @return Date the tree was planted. + */ + @JsonProperty("plantedDate") + public Optional getPlantedDate() { + return plantedDate; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof TreeRecord && equalTo((TreeRecord) other); + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + private boolean equalTo(TreeRecord other) { + return id.equals(other.id) + && treeName.equals(other.treeName) + && treeDescription.equals(other.treeDescription) + && treeSpecies.equals(other.treeSpecies) + && heightInFeet.equals(other.heightInFeet) + && plantedDate.equals(other.plantedDate); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash( + this.id, this.treeName, this.treeDescription, this.treeSpecies, this.heightInFeet, this.plantedDate); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static IdStage builder() { + return new Builder(); + } + + public interface IdStage { + /** + *

Unique tree identifier.

+ */ + TreeNameStage id(@NotNull String id); + + Builder from(TreeRecord other); + } + + public interface TreeNameStage { + /** + *

Display name of the tree.

+ */ + TreeSpeciesStage treeName(@NotNull String treeName); + } + + public interface TreeSpeciesStage { + /** + *

The species of tree.

+ */ + _FinalStage treeSpecies(@NotNull String treeSpecies); + } + + public interface _FinalStage { + TreeRecord build(); + + _FinalStage additionalProperty(String key, Object value); + + _FinalStage additionalProperties(Map additionalProperties); + + /** + *

A description of the tree.

+ */ + _FinalStage treeDescription(Optional treeDescription); + + _FinalStage treeDescription(String treeDescription); + + /** + *

Height of the tree in feet.

+ */ + _FinalStage heightInFeet(Optional heightInFeet); + + _FinalStage heightInFeet(Double heightInFeet); + + /** + *

Date the tree was planted.

+ */ + _FinalStage plantedDate(Optional plantedDate); + + _FinalStage plantedDate(String plantedDate); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Builder implements IdStage, TreeNameStage, TreeSpeciesStage, _FinalStage { + private String id; + + private String treeName; + + private String treeSpecies; + + private Optional plantedDate = Optional.empty(); + + private Optional heightInFeet = Optional.empty(); + + private Optional treeDescription = Optional.empty(); + + @JsonAnySetter + private Map additionalProperties = new HashMap<>(); + + private Builder() {} + + @java.lang.Override + public Builder from(TreeRecord other) { + id(other.getId()); + treeName(other.getTreeName()); + treeDescription(other.getTreeDescription()); + treeSpecies(other.getTreeSpecies()); + heightInFeet(other.getHeightInFeet()); + plantedDate(other.getPlantedDate()); + return this; + } + + /** + *

Unique tree identifier.

+ *

Unique tree identifier.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("id") + public TreeNameStage id(@NotNull String id) { + this.id = Objects.requireNonNull(id, "id must not be null"); + return this; + } + + /** + *

Display name of the tree.

+ *

Display name of the tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("treeName") + public TreeSpeciesStage treeName(@NotNull String treeName) { + this.treeName = Objects.requireNonNull(treeName, "treeName must not be null"); + return this; + } + + /** + *

The species of tree.

+ *

The species of tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + @JsonSetter("treeSpecies") + public _FinalStage treeSpecies(@NotNull String treeSpecies) { + this.treeSpecies = Objects.requireNonNull(treeSpecies, "treeSpecies must not be null"); + return this; + } + + /** + *

Date the tree was planted.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage plantedDate(String plantedDate) { + this.plantedDate = Optional.ofNullable(plantedDate); + return this; + } + + /** + *

Date the tree was planted.

+ */ + @java.lang.Override + @JsonSetter(value = "plantedDate", nulls = Nulls.SKIP) + public _FinalStage plantedDate(Optional plantedDate) { + this.plantedDate = plantedDate; + return this; + } + + /** + *

Height of the tree in feet.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage heightInFeet(Double heightInFeet) { + this.heightInFeet = Optional.ofNullable(heightInFeet); + return this; + } + + /** + *

Height of the tree in feet.

+ */ + @java.lang.Override + @JsonSetter(value = "heightInFeet", nulls = Nulls.SKIP) + public _FinalStage heightInFeet(Optional heightInFeet) { + this.heightInFeet = heightInFeet; + return this; + } + + /** + *

A description of the tree.

+ * @return Reference to {@code this} so that method calls can be chained together. + */ + @java.lang.Override + public _FinalStage treeDescription(String treeDescription) { + this.treeDescription = Optional.ofNullable(treeDescription); + return this; + } + + /** + *

A description of the tree.

+ */ + @java.lang.Override + @JsonSetter(value = "treeDescription", nulls = Nulls.SKIP) + public _FinalStage treeDescription(Optional treeDescription) { + this.treeDescription = treeDescription; + return this; + } + + @java.lang.Override + public TreeRecord build() { + return new TreeRecord( + id, treeName, treeDescription, treeSpecies, heightInFeet, plantedDate, additionalProperties); + } + + @java.lang.Override + public Builder additionalProperty(String key, Object value) { + this.additionalProperties.put(key, value); + return this; + } + + @java.lang.Override + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties.putAll(additionalProperties); + return this; + } + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example10.java b/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example10.java new file mode 100644 index 000000000000..398d39230ced --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example10.java @@ -0,0 +1,22 @@ +package com.snippets; + +import com.seed.api.SeedApiClient; +import com.seed.api.requests.PlantPost; +import com.seed.api.types.PlantPostSunExposure; +import com.seed.api.types.PlantPostWateringFrequency; + +public class Example10 { + public static void main(String[] args) { + SeedApiClient client = + SeedApiClient.builder().url("https://api.fern.com").build(); + + client.createPlant(PlantPost.builder() + .species("species") + .family("family") + .genus("genus") + .commonName("commonName") + .wateringFrequency(PlantPostWateringFrequency.DAILY) + .sunExposure(PlantPostSunExposure.FULL) + .build()); + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example11.java b/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example11.java new file mode 100644 index 000000000000..a8f284c2a50d --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example11.java @@ -0,0 +1,24 @@ +package com.snippets; + +import com.seed.api.SeedApiClient; +import com.seed.api.requests.PlantPost; +import com.seed.api.types.PlantPostSunExposure; +import com.seed.api.types.PlantPostWateringFrequency; + +public class Example11 { + public static void main(String[] args) { + SeedApiClient client = + SeedApiClient.builder().url("https://api.fern.com").build(); + + client.createPlant(PlantPost.builder() + .species("species") + .family("family") + .genus("genus") + .commonName("commonName") + .wateringFrequency(PlantPostWateringFrequency.DAILY) + .sunExposure(PlantPostSunExposure.FULL) + .plantedAt("2023-01-15") + .soilType("soilType") + .build()); + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example12.java b/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example12.java new file mode 100644 index 000000000000..c256da83d8ce --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example12.java @@ -0,0 +1,17 @@ +package com.snippets; + +import com.seed.api.SeedApiClient; +import com.seed.api.types.TreeRecord; + +public class Example12 { + public static void main(String[] args) { + SeedApiClient client = + SeedApiClient.builder().url("https://api.fern.com").build(); + + client.createTree(TreeRecord.builder() + .id("id") + .treeName("treeName") + .treeSpecies("treeSpecies") + .build()); + } +} diff --git a/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example13.java b/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example13.java new file mode 100644 index 000000000000..7be9154642c9 --- /dev/null +++ b/seed/java-sdk/allof-inline/src/main/java/com/snippets/Example13.java @@ -0,0 +1,20 @@ +package com.snippets; + +import com.seed.api.SeedApiClient; +import com.seed.api.types.TreeRecord; + +public class Example13 { + public static void main(String[] args) { + SeedApiClient client = + SeedApiClient.builder().url("https://api.fern.com").build(); + + client.createTree(TreeRecord.builder() + .id("id") + .treeName("treeName") + .treeSpecies("treeSpecies") + .treeDescription("treeDescription") + .heightInFeet(1.1) + .plantedDate("2023-01-15") + .build()); + } +} diff --git a/seed/java-sdk/allof/.fern/metadata.json b/seed/java-sdk/allof/.fern/metadata.json index e52ea4749838..8558dfdb1357 100644 --- a/seed/java-sdk/allof/.fern/metadata.json +++ b/seed/java-sdk/allof/.fern/metadata.json @@ -3,7 +3,8 @@ "generatorName": "fernapi/fern-java-sdk", "generatorVersion": "local", "originGitCommit": "DUMMY", - "invokedBy": "manual", + "invokedBy": "ci", "requestedVersion": "0.0.1", + "ciProvider": "github", "sdkVersion": "0.0.1" } \ No newline at end of file diff --git a/seed/openapi/allof-inline/openapi.yml b/seed/openapi/allof-inline/openapi.yml index d4b725927f85..be8901520b49 100644 --- a/seed/openapi/allof-inline/openapi.yml +++ b/seed/openapi/allof-inline/openapi.yml @@ -149,6 +149,127 @@ paths: domain: domain name: name summary: Get an organization with merged object-typed properties + /plants: + post: + description: >- + Tests three-level allOf chain where a parent schema itself uses allOf + with $ref elements. The grandparent's properties must be resolved + through the nested $ref. + operationId: createPlant + tags: + - '' + parameters: [] + responses: + '200': + description: Created plant + content: + application/json: + schema: + $ref: '#/components/schemas/PlantStrict' + examples: + Example1: + value: + species: species + family: family + genus: genus + summary: Create a plant (nested allOf with $ref chain) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + species: + type: string + description: The botanical species name. + examples: + - species + family: + type: string + description: The botanical family. + examples: + - family + genus: + type: string + description: The botanical genus. + examples: + - genus + commonName: + type: string + description: The common name of the plant. + examples: + - commonName + wateringFrequency: + $ref: '#/components/schemas/PlantPostWateringFrequency' + sunExposure: + $ref: '#/components/schemas/PlantPostSunExposure' + description: Required sun exposure level. + plantedAt: + type: + - string + - 'null' + format: date + description: Date the plant was planted. + soilType: + type: + - string + - 'null' + description: Preferred soil type. + required: + - species + - family + - genus + - commonName + - wateringFrequency + - sunExposure + examples: + Example1: + value: + species: species + family: family + genus: genus + commonName: commonName + wateringFrequency: daily + sunExposure: full + /trees: + post: + description: >- + Tests that when a parent's allOf contains multiple $ref entries, all of + them are resolved and their properties merged. + operationId: createTree + tags: + - '' + parameters: [] + responses: + '200': + description: Created tree + content: + application/json: + schema: + $ref: '#/components/schemas/TreeRecord' + examples: + Example1: + value: + id: id + treeName: treeName + treeDescription: treeDescription + treeSpecies: treeSpecies + heightInFeet: 1.1 + plantedDate: '2023-01-15' + summary: Create a tree (multiple nested $ref in parent allOf) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TreeRecord' + examples: + Example1: + value: + id: id + treeName: treeName + treeSpecies: treeSpecies components: schemas: RuleCreateRequestExecutionContext: @@ -159,6 +280,22 @@ components: - staging - dev description: Execution context for the rule, excluding the prod environment. + PlantPostWateringFrequency: + title: PlantPostWateringFrequency + type: string + enum: + - daily + - weekly + - biweekly + - monthly + PlantPostSunExposure: + title: PlantPostSunExposure + type: string + enum: + - full + - partial + - shade + description: Required sun exposure level. PaginatedResult: title: PaginatedResult type: object @@ -468,6 +605,159 @@ components: required: - id - name + PlantStrict: + title: PlantStrict + type: object + properties: + species: + type: string + description: The botanical species name. + examples: + - species + family: + type: string + description: The botanical family. + examples: + - family + genus: + type: string + description: The botanical genus. + examples: + - genus + required: + - species + - family + - genus + PlantBaseWateringFrequency: + title: PlantBaseWateringFrequency + type: string + enum: + - daily + - weekly + - biweekly + - monthly + PlantBase: + title: PlantBase + type: object + properties: + species: + type: string + description: The botanical species name. + family: + type: string + description: The botanical family. + genus: + type: string + description: The botanical genus. + commonName: + type: + - string + - 'null' + description: The common name of the plant. + wateringFrequency: + anyOf: + - $ref: '#/components/schemas/PlantBaseWateringFrequency' + - type: 'null' + required: + - species + - family + - genus + TreeIdentifiable: + title: TreeIdentifiable + type: object + properties: + id: + type: string + format: uuid + description: Unique tree identifier. + required: + - id + TreeDescribable: + title: TreeDescribable + type: object + properties: + treeName: + type: + - string + - 'null' + description: Display name of the tree. + treeDescription: + type: + - string + - 'null' + description: A description of the tree. + TreeBase: + title: TreeBase + type: object + properties: + id: + type: string + format: uuid + description: Unique tree identifier. + treeName: + type: + - string + - 'null' + description: Display name of the tree. + treeDescription: + type: + - string + - 'null' + description: A description of the tree. + treeSpecies: + type: + - string + - 'null' + description: The species of tree. + heightInFeet: + type: + - number + - 'null' + format: double + description: Height of the tree in feet. + required: + - id + TreeRecord: + title: TreeRecord + type: object + properties: + id: + type: string + format: uuid + description: Unique tree identifier. + examples: + - id + treeName: + type: string + description: Display name of the tree. + examples: + - treeName + treeDescription: + type: + - string + - 'null' + description: A description of the tree. + treeSpecies: + type: string + description: The species of tree. + examples: + - treeSpecies + heightInFeet: + type: + - number + - 'null' + format: double + description: Height of the tree in feet. + plantedDate: + type: + - string + - 'null' + format: date + description: Date the tree was planted. + required: + - id + - treeName + - treeSpecies securitySchemes: {} servers: - url: https://api.example.com diff --git a/seed/openapi/allof/openapi.yml b/seed/openapi/allof/openapi.yml index 971c82c6b12f..4b34c49d8fe9 100644 --- a/seed/openapi/allof/openapi.yml +++ b/seed/openapi/allof/openapi.yml @@ -149,6 +149,98 @@ paths: id: id name: name summary: Get an organization with merged object-typed properties + /plants: + post: + description: >- + Tests three-level allOf chain where a parent schema itself uses allOf + with $ref elements. The grandparent's properties must be resolved + through the nested $ref. + operationId: createPlant + tags: + - '' + parameters: [] + responses: + '200': + description: Created plant + content: + application/json: + schema: + $ref: '#/components/schemas/PlantStrict' + examples: + Example1: + value: + species: species + family: family + genus: genus + summary: Create a plant (nested allOf with $ref chain) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + sunExposure: + $ref: '#/components/schemas/PlantPostSunExposure' + description: Required sun exposure level. + plantedAt: + type: + - string + - 'null' + format: date + description: Date the plant was planted. + soilType: + type: + - string + - 'null' + description: Preferred soil type. + required: + - sunExposure + allOf: + - $ref: '#/components/schemas/PlantBase' + examples: + Example1: + value: + species: species + family: family + genus: genus + sunExposure: full + /trees: + post: + description: >- + Tests that when a parent's allOf contains multiple $ref entries, all of + them are resolved and their properties merged. + operationId: createTree + tags: + - '' + parameters: [] + responses: + '200': + description: Created tree + content: + application/json: + schema: + $ref: '#/components/schemas/TreeRecord' + examples: + Example1: + value: + treeName: treeName + treeDescription: treeDescription + id: id + treeSpecies: treeSpecies + heightInFeet: 1.1 + plantedDate: '2023-01-15' + summary: Create a tree (multiple nested $ref in parent allOf) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TreeRecord' + examples: + Example1: + value: + id: id components: schemas: RuleCreateRequestExecutionContext: @@ -159,6 +251,14 @@ components: - staging - dev description: Execution context for the rule, excluding the prod environment. + PlantPostSunExposure: + title: PlantPostSunExposure + type: string + enum: + - full + - partial + - shade + description: Required sun exposure level. PaginatedResult: title: PaginatedResult type: object @@ -434,6 +534,106 @@ components: required: - name - id + PlantStrict: + title: PlantStrict + type: object + properties: + species: + type: string + description: The botanical species name. + examples: + - species + family: + type: string + description: The botanical family. + examples: + - family + genus: + type: string + description: The botanical genus. + examples: + - genus + required: + - species + - family + - genus + PlantBaseWateringFrequency: + title: PlantBaseWateringFrequency + type: string + enum: + - daily + - weekly + - biweekly + - monthly + PlantBase: + title: PlantBase + type: object + properties: + commonName: + type: + - string + - 'null' + description: The common name of the plant. + wateringFrequency: + anyOf: + - $ref: '#/components/schemas/PlantBaseWateringFrequency' + - type: 'null' + allOf: + - $ref: '#/components/schemas/PlantStrict' + TreeIdentifiable: + title: TreeIdentifiable + type: object + properties: + id: + type: string + format: uuid + description: Unique tree identifier. + required: + - id + TreeDescribable: + title: TreeDescribable + type: object + properties: + treeName: + type: + - string + - 'null' + description: Display name of the tree. + treeDescription: + type: + - string + - 'null' + description: A description of the tree. + TreeBase: + title: TreeBase + type: object + properties: + treeSpecies: + type: + - string + - 'null' + description: The species of tree. + heightInFeet: + type: + - number + - 'null' + format: double + description: Height of the tree in feet. + allOf: + - $ref: '#/components/schemas/TreeIdentifiable' + - $ref: '#/components/schemas/TreeDescribable' + TreeRecord: + title: TreeRecord + type: object + properties: + plantedDate: + type: + - string + - 'null' + format: date + description: Date the tree was planted. + allOf: + - $ref: '#/components/schemas/TreeBase' securitySchemes: {} servers: - url: https://api.example.com diff --git a/seed/php-sdk/allof-inline/reference.md b/seed/php-sdk/allof-inline/reference.md index 73efd592aa6b..a8e3b1d52067 100644 --- a/seed/php-sdk/allof-inline/reference.md +++ b/seed/php-sdk/allof-inline/reference.md @@ -169,3 +169,182 @@ $client->getOrganization(); +
$client->createPlant($request) -> ?PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->createPlant( + new PlantPost([ + 'species' => 'species', + 'family' => 'family', + 'genus' => 'genus', + 'commonName' => 'commonName', + 'wateringFrequency' => PlantPostWateringFrequency::Daily->value, + 'sunExposure' => PlantPostSunExposure::Full->value, + ]), +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$species:** `string` — The botanical species name. + +
+
+ +
+
+ +**$family:** `string` — The botanical family. + +
+
+ +
+
+ +**$genus:** `string` — The botanical genus. + +
+
+ +
+
+ +**$commonName:** `string` — The common name of the plant. + +
+
+ +
+
+ +**$wateringFrequency:** `string` + +
+
+ +
+
+ +**$sunExposure:** `string` — Required sun exposure level. + +
+
+ +
+
+ +**$plantedAt:** `?DateTime` — Date the plant was planted. + +
+
+ +
+
+ +**$soilType:** `?string` — Preferred soil type. + +
+
+
+
+ + +
+
+
+ +
$client->createTree($request) -> ?TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->createTree( + new TreeRecord([ + 'id' => 'id', + 'treeName' => 'treeName', + 'treeSpecies' => 'treeSpecies', + ]), +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$request:** `TreeRecord` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/php-sdk/allof-inline/src/Requests/PlantPost.php b/seed/php-sdk/allof-inline/src/Requests/PlantPost.php new file mode 100644 index 000000000000..51e6a6b4266c --- /dev/null +++ b/seed/php-sdk/allof-inline/src/Requests/PlantPost.php @@ -0,0 +1,86 @@ + $wateringFrequency + */ + #[JsonProperty('wateringFrequency')] + public string $wateringFrequency; + + /** + * @var value-of $sunExposure Required sun exposure level. + */ + #[JsonProperty('sunExposure')] + public string $sunExposure; + + /** + * @var ?DateTime $plantedAt Date the plant was planted. + */ + #[JsonProperty('plantedAt'), Date(Date::TYPE_DATE)] + public ?DateTime $plantedAt; + + /** + * @var ?string $soilType Preferred soil type. + */ + #[JsonProperty('soilType')] + public ?string $soilType; + + /** + * @param array{ + * species: string, + * family: string, + * genus: string, + * commonName: string, + * wateringFrequency: value-of, + * sunExposure: value-of, + * plantedAt?: ?DateTime, + * soilType?: ?string, + * } $values + */ + public function __construct( + array $values, + ) { + $this->species = $values['species']; + $this->family = $values['family']; + $this->genus = $values['genus']; + $this->commonName = $values['commonName']; + $this->wateringFrequency = $values['wateringFrequency']; + $this->sunExposure = $values['sunExposure']; + $this->plantedAt = $values['plantedAt'] ?? null; + $this->soilType = $values['soilType'] ?? null; + } +} diff --git a/seed/php-sdk/allof-inline/src/SeedClient.php b/seed/php-sdk/allof-inline/src/SeedClient.php index 63b560f9b6c7..24bb0e54facb 100644 --- a/seed/php-sdk/allof-inline/src/SeedClient.php +++ b/seed/php-sdk/allof-inline/src/SeedClient.php @@ -17,6 +17,9 @@ use Seed\Types\UserSearchResponse; use Seed\Types\CombinedEntity; use Seed\Types\Organization; +use Seed\Requests\PlantPost; +use Seed\Types\PlantStrict; +use Seed\Types\TreeRecord; class SeedClient { @@ -299,4 +302,102 @@ public function getOrganization(?array $options = null): ?Organization body: $response->getBody()->getContents(), ); } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + * + * @param PlantPost $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?PlantStrict + * @throws SeedException + * @throws SeedApiException + */ + public function createPlant(PlantPost $request, ?array $options = null): ?PlantStrict + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? Environments::Default_->value, + path: "plants", + method: HttpMethod::POST, + body: $request, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return PlantStrict::fromJson($json); + } + } catch (JsonException $e) { + throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new SeedException(message: $e->getMessage(), previous: $e); + } + throw new SeedApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + * + * @param TreeRecord $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?TreeRecord + * @throws SeedException + * @throws SeedApiException + */ + public function createTree(TreeRecord $request, ?array $options = null): ?TreeRecord + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? Environments::Default_->value, + path: "trees", + method: HttpMethod::POST, + body: $request, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return TreeRecord::fromJson($json); + } + } catch (JsonException $e) { + throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new SeedException(message: $e->getMessage(), previous: $e); + } + throw new SeedApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } } diff --git a/seed/php-sdk/allof-inline/src/Types/PlantBase.php b/seed/php-sdk/allof-inline/src/Types/PlantBase.php new file mode 100644 index 000000000000..d052f9212d35 --- /dev/null +++ b/seed/php-sdk/allof-inline/src/Types/PlantBase.php @@ -0,0 +1,66 @@ + $wateringFrequency + */ + #[JsonProperty('wateringFrequency')] + public ?string $wateringFrequency; + + /** + * @param array{ + * species: string, + * family: string, + * genus: string, + * commonName?: ?string, + * wateringFrequency?: ?value-of, + * } $values + */ + public function __construct( + array $values, + ) { + $this->species = $values['species']; + $this->family = $values['family']; + $this->genus = $values['genus']; + $this->commonName = $values['commonName'] ?? null; + $this->wateringFrequency = $values['wateringFrequency'] ?? null; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof-inline/src/Types/PlantBaseWateringFrequency.php b/seed/php-sdk/allof-inline/src/Types/PlantBaseWateringFrequency.php new file mode 100644 index 000000000000..9039da9d1d03 --- /dev/null +++ b/seed/php-sdk/allof-inline/src/Types/PlantBaseWateringFrequency.php @@ -0,0 +1,11 @@ +species = $values['species']; + $this->family = $values['family']; + $this->genus = $values['genus']; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof-inline/src/Types/TreeBase.php b/seed/php-sdk/allof-inline/src/Types/TreeBase.php new file mode 100644 index 000000000000..e2552c858c78 --- /dev/null +++ b/seed/php-sdk/allof-inline/src/Types/TreeBase.php @@ -0,0 +1,66 @@ +id = $values['id']; + $this->treeName = $values['treeName'] ?? null; + $this->treeDescription = $values['treeDescription'] ?? null; + $this->treeSpecies = $values['treeSpecies'] ?? null; + $this->heightInFeet = $values['heightInFeet'] ?? null; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof-inline/src/Types/TreeDescribable.php b/seed/php-sdk/allof-inline/src/Types/TreeDescribable.php new file mode 100644 index 000000000000..9d94dd6c037d --- /dev/null +++ b/seed/php-sdk/allof-inline/src/Types/TreeDescribable.php @@ -0,0 +1,42 @@ +treeName = $values['treeName'] ?? null; + $this->treeDescription = $values['treeDescription'] ?? null; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof-inline/src/Types/TreeIdentifiable.php b/seed/php-sdk/allof-inline/src/Types/TreeIdentifiable.php new file mode 100644 index 000000000000..3e1bca3d6f58 --- /dev/null +++ b/seed/php-sdk/allof-inline/src/Types/TreeIdentifiable.php @@ -0,0 +1,34 @@ +id = $values['id']; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof-inline/src/Types/TreeRecord.php b/seed/php-sdk/allof-inline/src/Types/TreeRecord.php new file mode 100644 index 000000000000..03cf80f011d8 --- /dev/null +++ b/seed/php-sdk/allof-inline/src/Types/TreeRecord.php @@ -0,0 +1,76 @@ +id = $values['id']; + $this->treeName = $values['treeName']; + $this->treeDescription = $values['treeDescription'] ?? null; + $this->treeSpecies = $values['treeSpecies']; + $this->heightInFeet = $values['heightInFeet'] ?? null; + $this->plantedDate = $values['plantedDate'] ?? null; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof-inline/src/dynamic-snippets/example10/snippet.php b/seed/php-sdk/allof-inline/src/dynamic-snippets/example10/snippet.php new file mode 100644 index 000000000000..a420c8402f9f --- /dev/null +++ b/seed/php-sdk/allof-inline/src/dynamic-snippets/example10/snippet.php @@ -0,0 +1,24 @@ + 'https://api.fern.com', + ], +); +$client->createPlant( + new PlantPost([ + 'species' => 'species', + 'family' => 'family', + 'genus' => 'genus', + 'commonName' => 'commonName', + 'wateringFrequency' => PlantPostWateringFrequency::Daily->value, + 'sunExposure' => PlantPostSunExposure::Full->value, + ]), +); diff --git a/seed/php-sdk/allof-inline/src/dynamic-snippets/example11/snippet.php b/seed/php-sdk/allof-inline/src/dynamic-snippets/example11/snippet.php new file mode 100644 index 000000000000..a676a088e198 --- /dev/null +++ b/seed/php-sdk/allof-inline/src/dynamic-snippets/example11/snippet.php @@ -0,0 +1,27 @@ + 'https://api.fern.com', + ], +); +$client->createPlant( + new PlantPost([ + 'species' => 'species', + 'family' => 'family', + 'genus' => 'genus', + 'commonName' => 'commonName', + 'wateringFrequency' => PlantPostWateringFrequency::Daily->value, + 'sunExposure' => PlantPostSunExposure::Full->value, + 'plantedAt' => new DateTime('2023-01-15'), + 'soilType' => 'soilType', + ]), +); diff --git a/seed/php-sdk/allof-inline/src/dynamic-snippets/example12/snippet.php b/seed/php-sdk/allof-inline/src/dynamic-snippets/example12/snippet.php new file mode 100644 index 000000000000..e65959fc08a4 --- /dev/null +++ b/seed/php-sdk/allof-inline/src/dynamic-snippets/example12/snippet.php @@ -0,0 +1,19 @@ + 'https://api.fern.com', + ], +); +$client->createTree( + new TreeRecord([ + 'id' => 'id', + 'treeName' => 'treeName', + 'treeSpecies' => 'treeSpecies', + ]), +); diff --git a/seed/php-sdk/allof-inline/src/dynamic-snippets/example13/snippet.php b/seed/php-sdk/allof-inline/src/dynamic-snippets/example13/snippet.php new file mode 100644 index 000000000000..01157fc0e3de --- /dev/null +++ b/seed/php-sdk/allof-inline/src/dynamic-snippets/example13/snippet.php @@ -0,0 +1,23 @@ + 'https://api.fern.com', + ], +); +$client->createTree( + new TreeRecord([ + 'id' => 'id', + 'treeName' => 'treeName', + 'treeDescription' => 'treeDescription', + 'treeSpecies' => 'treeSpecies', + 'heightInFeet' => 1.1, + 'plantedDate' => new DateTime('2023-01-15'), + ]), +); diff --git a/seed/php-sdk/allof/reference.md b/seed/php-sdk/allof/reference.md index 73efd592aa6b..b23d65122a7f 100644 --- a/seed/php-sdk/allof/reference.md +++ b/seed/php-sdk/allof/reference.md @@ -169,3 +169,138 @@ $client->getOrganization(); +
$client->createPlant($request) -> ?PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->createPlant( + new PlantPost([ + 'species' => 'species', + 'family' => 'family', + 'genus' => 'genus', + 'sunExposure' => PlantPostSunExposure::Full->value, + ]), +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$sunExposure:** `string` — Required sun exposure level. + +
+
+ +
+
+ +**$plantedAt:** `?DateTime` — Date the plant was planted. + +
+
+ +
+
+ +**$soilType:** `?string` — Preferred soil type. + +
+
+
+
+ + +
+
+
+ +
$client->createTree($request) -> ?TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->createTree( + new TreeRecord([ + 'id' => 'id', + ]), +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$request:** `TreeRecord` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/php-sdk/allof/src/Requests/PlantPost.php b/seed/php-sdk/allof/src/Requests/PlantPost.php new file mode 100644 index 000000000000..671d5cd0df48 --- /dev/null +++ b/seed/php-sdk/allof/src/Requests/PlantPost.php @@ -0,0 +1,59 @@ + $sunExposure Required sun exposure level. + */ + #[JsonProperty('sunExposure')] + public string $sunExposure; + + /** + * @var ?DateTime $plantedAt Date the plant was planted. + */ + #[JsonProperty('plantedAt'), Date(Date::TYPE_DATE)] + public ?DateTime $plantedAt; + + /** + * @var ?string $soilType Preferred soil type. + */ + #[JsonProperty('soilType')] + public ?string $soilType; + + /** + * @param array{ + * sunExposure: value-of, + * species: string, + * family: string, + * genus: string, + * plantedAt?: ?DateTime, + * soilType?: ?string, + * commonName?: ?string, + * wateringFrequency?: ?value-of, + * } $values + */ + public function __construct( + array $values, + ) { + $this->sunExposure = $values['sunExposure']; + $this->plantedAt = $values['plantedAt'] ?? null; + $this->soilType = $values['soilType'] ?? null; + $this->commonName = $values['commonName'] ?? null; + $this->wateringFrequency = $values['wateringFrequency'] ?? null; + $this->species = $values['species']; + $this->family = $values['family']; + $this->genus = $values['genus']; + } +} diff --git a/seed/php-sdk/allof/src/SeedClient.php b/seed/php-sdk/allof/src/SeedClient.php index 63b560f9b6c7..24bb0e54facb 100644 --- a/seed/php-sdk/allof/src/SeedClient.php +++ b/seed/php-sdk/allof/src/SeedClient.php @@ -17,6 +17,9 @@ use Seed\Types\UserSearchResponse; use Seed\Types\CombinedEntity; use Seed\Types\Organization; +use Seed\Requests\PlantPost; +use Seed\Types\PlantStrict; +use Seed\Types\TreeRecord; class SeedClient { @@ -299,4 +302,102 @@ public function getOrganization(?array $options = null): ?Organization body: $response->getBody()->getContents(), ); } + + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + * + * @param PlantPost $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?PlantStrict + * @throws SeedException + * @throws SeedApiException + */ + public function createPlant(PlantPost $request, ?array $options = null): ?PlantStrict + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? Environments::Default_->value, + path: "plants", + method: HttpMethod::POST, + body: $request, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return PlantStrict::fromJson($json); + } + } catch (JsonException $e) { + throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new SeedException(message: $e->getMessage(), previous: $e); + } + throw new SeedApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + * + * @param TreeRecord $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?TreeRecord + * @throws SeedException + * @throws SeedApiException + */ + public function createTree(TreeRecord $request, ?array $options = null): ?TreeRecord + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? Environments::Default_->value, + path: "trees", + method: HttpMethod::POST, + body: $request, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return TreeRecord::fromJson($json); + } + } catch (JsonException $e) { + throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new SeedException(message: $e->getMessage(), previous: $e); + } + throw new SeedApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } } diff --git a/seed/php-sdk/allof/src/Traits/PlantBase.php b/seed/php-sdk/allof/src/Traits/PlantBase.php new file mode 100644 index 000000000000..41ac92a8c5cf --- /dev/null +++ b/seed/php-sdk/allof/src/Traits/PlantBase.php @@ -0,0 +1,27 @@ + $wateringFrequency + */ +trait PlantBase +{ + use PlantStrict; + + /** + * @var ?string $commonName The common name of the plant. + */ + #[JsonProperty('commonName')] + public ?string $commonName; + + /** + * @var ?value-of $wateringFrequency + */ + #[JsonProperty('wateringFrequency')] + public ?string $wateringFrequency; +} diff --git a/seed/php-sdk/allof/src/Traits/PlantStrict.php b/seed/php-sdk/allof/src/Traits/PlantStrict.php new file mode 100644 index 000000000000..58b2d293642d --- /dev/null +++ b/seed/php-sdk/allof/src/Traits/PlantStrict.php @@ -0,0 +1,31 @@ + $wateringFrequency + */ + #[JsonProperty('wateringFrequency')] + public ?string $wateringFrequency; + + /** + * @param array{ + * species: string, + * family: string, + * genus: string, + * commonName?: ?string, + * wateringFrequency?: ?value-of, + * } $values + */ + public function __construct( + array $values, + ) { + $this->species = $values['species']; + $this->family = $values['family']; + $this->genus = $values['genus']; + $this->commonName = $values['commonName'] ?? null; + $this->wateringFrequency = $values['wateringFrequency'] ?? null; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof/src/Types/PlantBaseWateringFrequency.php b/seed/php-sdk/allof/src/Types/PlantBaseWateringFrequency.php new file mode 100644 index 000000000000..9039da9d1d03 --- /dev/null +++ b/seed/php-sdk/allof/src/Types/PlantBaseWateringFrequency.php @@ -0,0 +1,11 @@ +species = $values['species']; + $this->family = $values['family']; + $this->genus = $values['genus']; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof/src/Types/TreeBase.php b/seed/php-sdk/allof/src/Types/TreeBase.php new file mode 100644 index 000000000000..b744f36508fa --- /dev/null +++ b/seed/php-sdk/allof/src/Types/TreeBase.php @@ -0,0 +1,53 @@ +id = $values['id']; + $this->treeName = $values['treeName'] ?? null; + $this->treeDescription = $values['treeDescription'] ?? null; + $this->treeSpecies = $values['treeSpecies'] ?? null; + $this->heightInFeet = $values['heightInFeet'] ?? null; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof/src/Types/TreeDescribable.php b/seed/php-sdk/allof/src/Types/TreeDescribable.php new file mode 100644 index 000000000000..9d94dd6c037d --- /dev/null +++ b/seed/php-sdk/allof/src/Types/TreeDescribable.php @@ -0,0 +1,42 @@ +treeName = $values['treeName'] ?? null; + $this->treeDescription = $values['treeDescription'] ?? null; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof/src/Types/TreeIdentifiable.php b/seed/php-sdk/allof/src/Types/TreeIdentifiable.php new file mode 100644 index 000000000000..3e1bca3d6f58 --- /dev/null +++ b/seed/php-sdk/allof/src/Types/TreeIdentifiable.php @@ -0,0 +1,34 @@ +id = $values['id']; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof/src/Types/TreeRecord.php b/seed/php-sdk/allof/src/Types/TreeRecord.php new file mode 100644 index 000000000000..77438ad556ef --- /dev/null +++ b/seed/php-sdk/allof/src/Types/TreeRecord.php @@ -0,0 +1,49 @@ +treeSpecies = $values['treeSpecies'] ?? null; + $this->heightInFeet = $values['heightInFeet'] ?? null; + $this->id = $values['id']; + $this->treeName = $values['treeName'] ?? null; + $this->treeDescription = $values['treeDescription'] ?? null; + $this->plantedDate = $values['plantedDate'] ?? null; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/allof/src/dynamic-snippets/example10/snippet.php b/seed/php-sdk/allof/src/dynamic-snippets/example10/snippet.php new file mode 100644 index 000000000000..0dca4acae9b1 --- /dev/null +++ b/seed/php-sdk/allof/src/dynamic-snippets/example10/snippet.php @@ -0,0 +1,21 @@ + 'https://api.fern.com', + ], +); +$client->createPlant( + new PlantPost([ + 'species' => 'species', + 'family' => 'family', + 'genus' => 'genus', + 'sunExposure' => PlantPostSunExposure::Full->value, + ]), +); diff --git a/seed/php-sdk/allof/src/dynamic-snippets/example11/snippet.php b/seed/php-sdk/allof/src/dynamic-snippets/example11/snippet.php new file mode 100644 index 000000000000..a54946f78297 --- /dev/null +++ b/seed/php-sdk/allof/src/dynamic-snippets/example11/snippet.php @@ -0,0 +1,27 @@ + 'https://api.fern.com', + ], +); +$client->createPlant( + new PlantPost([ + 'commonName' => 'commonName', + 'wateringFrequency' => PlantBaseWateringFrequency::Daily->value, + 'species' => 'species', + 'family' => 'family', + 'genus' => 'genus', + 'sunExposure' => PlantPostSunExposure::Full->value, + 'plantedAt' => new DateTime('2023-01-15'), + 'soilType' => 'soilType', + ]), +); diff --git a/seed/php-sdk/allof/src/dynamic-snippets/example12/snippet.php b/seed/php-sdk/allof/src/dynamic-snippets/example12/snippet.php new file mode 100644 index 000000000000..f2cd444a427d --- /dev/null +++ b/seed/php-sdk/allof/src/dynamic-snippets/example12/snippet.php @@ -0,0 +1,17 @@ + 'https://api.fern.com', + ], +); +$client->createTree( + new TreeRecord([ + 'id' => 'id', + ]), +); diff --git a/seed/php-sdk/allof/src/dynamic-snippets/example13/snippet.php b/seed/php-sdk/allof/src/dynamic-snippets/example13/snippet.php new file mode 100644 index 000000000000..da04d1aa0af7 --- /dev/null +++ b/seed/php-sdk/allof/src/dynamic-snippets/example13/snippet.php @@ -0,0 +1,23 @@ + 'https://api.fern.com', + ], +); +$client->createTree( + new TreeRecord([ + 'treeSpecies' => 'treeSpecies', + 'heightInFeet' => 1.1, + 'id' => 'id', + 'treeName' => 'treeName', + 'treeDescription' => 'treeDescription', + 'plantedDate' => new DateTime('2023-01-15'), + ]), +); diff --git a/seed/python-sdk/accept-header/poetry.lock b/seed/python-sdk/accept-header/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/accept-header/poetry.lock +++ b/seed/python-sdk/accept-header/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/alias-extends/no-custom-config/poetry.lock b/seed/python-sdk/alias-extends/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/alias-extends/no-custom-config/poetry.lock +++ b/seed/python-sdk/alias-extends/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/alias-extends/no-inheritance-for-extended-models/poetry.lock b/seed/python-sdk/alias-extends/no-inheritance-for-extended-models/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/alias-extends/no-inheritance-for-extended-models/poetry.lock +++ b/seed/python-sdk/alias-extends/no-inheritance-for-extended-models/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/alias/poetry.lock b/seed/python-sdk/alias/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/alias/poetry.lock +++ b/seed/python-sdk/alias/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/allof-inline/no-custom-config/poetry.lock b/seed/python-sdk/allof-inline/no-custom-config/poetry.lock index e2e5802b45ab..67baca5d84d2 100644 --- a/seed/python-sdk/allof-inline/no-custom-config/poetry.lock +++ b/seed/python-sdk/allof-inline/no-custom-config/poetry.lock @@ -662,14 +662,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/allof-inline/no-custom-config/reference.md b/seed/python-sdk/allof-inline/no-custom-config/reference.md index c1e2e6167abc..b91aa95c86af 100644 --- a/seed/python-sdk/allof-inline/no-custom-config/reference.md +++ b/seed/python-sdk/allof-inline/no-custom-config/reference.md @@ -266,3 +266,210 @@ client.get_organization() +
client.create_plant(...) -> PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from seed import SeedApi +from seed.environment import SeedApiEnvironment + +client = SeedApi( + environment=SeedApiEnvironment.DEFAULT, +) + +client.create_plant( + species="species", + family="family", + genus="genus", + common_name="commonName", + watering_frequency="daily", + sun_exposure="full", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**species:** `str` — The botanical species name. + +
+
+ +
+
+ +**family:** `str` — The botanical family. + +
+
+ +
+
+ +**genus:** `str` — The botanical genus. + +
+
+ +
+
+ +**common_name:** `str` — The common name of the plant. + +
+
+ +
+
+ +**watering_frequency:** `PlantPostWateringFrequency` + +
+
+ +
+
+ +**sun_exposure:** `PlantPostSunExposure` — Required sun exposure level. + +
+
+ +
+
+ +**planted_at:** `typing.Optional[datetime.date]` — Date the plant was planted. + +
+
+ +
+
+ +**soil_type:** `typing.Optional[str]` — Preferred soil type. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.create_tree(...) -> TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from seed import SeedApi +from seed.environment import SeedApiEnvironment + +client = SeedApi( + environment=SeedApiEnvironment.DEFAULT, +) + +client.create_tree( + id="id", + tree_name="treeName", + tree_species="treeSpecies", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TreeRecord` + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ diff --git a/seed/python-sdk/allof-inline/no-custom-config/snippet.json b/seed/python-sdk/allof-inline/no-custom-config/snippet.json index f000c2ddf786..252778d04208 100644 --- a/seed/python-sdk/allof-inline/no-custom-config/snippet.json +++ b/seed/python-sdk/allof-inline/no-custom-config/snippet.json @@ -65,6 +65,32 @@ "async_client": "import asyncio\n\nfrom seed import AsyncSeedApi\n\nclient = AsyncSeedApi()\n\n\nasync def main() -> None:\n await client.get_organization()\n\n\nasyncio.run(main())\n", "type": "python" } + }, + { + "example_identifier": "default", + "id": { + "path": "/plants", + "method": "POST", + "identifier_override": "endpoint_.createPlant" + }, + "snippet": { + "sync_client": "from seed import SeedApi\n\nclient = SeedApi()\nclient.create_plant(\n species=\"species\",\n family=\"family\",\n genus=\"genus\",\n common_name=\"commonName\",\n watering_frequency=\"daily\",\n sun_exposure=\"full\",\n)\n", + "async_client": "import asyncio\n\nfrom seed import AsyncSeedApi\n\nclient = AsyncSeedApi()\n\n\nasync def main() -> None:\n await client.create_plant(\n species=\"species\",\n family=\"family\",\n genus=\"genus\",\n common_name=\"commonName\",\n watering_frequency=\"daily\",\n sun_exposure=\"full\",\n )\n\n\nasyncio.run(main())\n", + "type": "python" + } + }, + { + "example_identifier": "default", + "id": { + "path": "/trees", + "method": "POST", + "identifier_override": "endpoint_.createTree" + }, + "snippet": { + "sync_client": "from seed import SeedApi\n\nclient = SeedApi()\nclient.create_tree(\n id=\"id\",\n tree_name=\"treeName\",\n tree_species=\"treeSpecies\",\n)\n", + "async_client": "import asyncio\n\nfrom seed import AsyncSeedApi\n\nclient = AsyncSeedApi()\n\n\nasync def main() -> None:\n await client.create_tree(\n id=\"id\",\n tree_name=\"treeName\",\n tree_species=\"treeSpecies\",\n )\n\n\nasyncio.run(main())\n", + "type": "python" + } } ] } \ No newline at end of file diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/__init__.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/__init__.py index 282e67d4f87a..70e199572f9d 100644 --- a/seed/python-sdk/allof-inline/no-custom-config/src/seed/__init__.py +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/__init__.py @@ -20,12 +20,21 @@ OrganizationMetadata, PaginatedResult, PagingCursors, + PlantBase, + PlantBaseWateringFrequency, + PlantPostSunExposure, + PlantPostWateringFrequency, + PlantStrict, RuleCreateRequestExecutionContext, RuleExecutionContext, RuleResponse, RuleResponseStatus, RuleType, RuleTypeSearchResponse, + TreeBase, + TreeDescribable, + TreeIdentifiable, + TreeRecord, User, UserSearchResponse, ) @@ -50,6 +59,11 @@ "OrganizationMetadata": ".types", "PaginatedResult": ".types", "PagingCursors": ".types", + "PlantBase": ".types", + "PlantBaseWateringFrequency": ".types", + "PlantPostSunExposure": ".types", + "PlantPostWateringFrequency": ".types", + "PlantStrict": ".types", "RuleCreateRequestExecutionContext": ".types", "RuleExecutionContext": ".types", "RuleResponse": ".types", @@ -58,6 +72,10 @@ "RuleTypeSearchResponse": ".types", "SeedApi": ".client", "SeedApiEnvironment": ".environment", + "TreeBase": ".types", + "TreeDescribable": ".types", + "TreeIdentifiable": ".types", + "TreeRecord": ".types", "User": ".types", "UserSearchResponse": ".types", "__version__": ".version", @@ -102,6 +120,11 @@ def __dir__(): "OrganizationMetadata", "PaginatedResult", "PagingCursors", + "PlantBase", + "PlantBaseWateringFrequency", + "PlantPostSunExposure", + "PlantPostWateringFrequency", + "PlantStrict", "RuleCreateRequestExecutionContext", "RuleExecutionContext", "RuleResponse", @@ -110,6 +133,10 @@ def __dir__(): "RuleTypeSearchResponse", "SeedApi", "SeedApiEnvironment", + "TreeBase", + "TreeDescribable", + "TreeIdentifiable", + "TreeRecord", "User", "UserSearchResponse", "__version__", diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/client.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/client.py index 8335dca12e75..e806ca570263 100644 --- a/seed/python-sdk/allof-inline/no-custom-config/src/seed/client.py +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/client.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import datetime as dt import typing import httpx @@ -10,9 +11,13 @@ from .raw_client import AsyncRawSeedApi, RawSeedApi from .types.combined_entity import CombinedEntity from .types.organization import Organization +from .types.plant_post_sun_exposure import PlantPostSunExposure +from .types.plant_post_watering_frequency import PlantPostWateringFrequency +from .types.plant_strict import PlantStrict from .types.rule_create_request_execution_context import RuleCreateRequestExecutionContext from .types.rule_response import RuleResponse from .types.rule_type_search_response import RuleTypeSearchResponse +from .types.tree_record import TreeRecord from .types.user_search_response import UserSearchResponse # this is used as the default value for optional parameters @@ -233,6 +238,146 @@ def get_organization(self, *, request_options: typing.Optional[RequestOptions] = _response = self._raw_client.get_organization(request_options=request_options) return _response.data + def create_plant( + self, + *, + species: str, + family: str, + genus: str, + common_name: str, + watering_frequency: PlantPostWateringFrequency, + sun_exposure: PlantPostSunExposure, + planted_at: typing.Optional[dt.date] = OMIT, + soil_type: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PlantStrict: + """ + Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + + Parameters + ---------- + species : str + The botanical species name. + + family : str + The botanical family. + + genus : str + The botanical genus. + + common_name : str + The common name of the plant. + + watering_frequency : PlantPostWateringFrequency + + sun_exposure : PlantPostSunExposure + Required sun exposure level. + + planted_at : typing.Optional[dt.date] + Date the plant was planted. + + soil_type : typing.Optional[str] + Preferred soil type. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PlantStrict + Created plant + + Examples + -------- + from seed import SeedApi + + client = SeedApi() + client.create_plant( + species="species", + family="family", + genus="genus", + common_name="commonName", + watering_frequency="daily", + sun_exposure="full", + ) + """ + _response = self._raw_client.create_plant( + species=species, + family=family, + genus=genus, + common_name=common_name, + watering_frequency=watering_frequency, + sun_exposure=sun_exposure, + planted_at=planted_at, + soil_type=soil_type, + request_options=request_options, + ) + return _response.data + + def create_tree( + self, + *, + id: str, + tree_name: str, + tree_species: str, + tree_description: typing.Optional[str] = OMIT, + height_in_feet: typing.Optional[float] = OMIT, + planted_date: typing.Optional[dt.date] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> TreeRecord: + """ + Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + + Parameters + ---------- + id : str + Unique tree identifier. + + tree_name : str + Display name of the tree. + + tree_species : str + The species of tree. + + tree_description : typing.Optional[str] + A description of the tree. + + height_in_feet : typing.Optional[float] + Height of the tree in feet. + + planted_date : typing.Optional[dt.date] + Date the tree was planted. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TreeRecord + Created tree + + Examples + -------- + from seed import SeedApi + + client = SeedApi() + client.create_tree( + id="id", + tree_name="treeName", + tree_species="treeSpecies", + ) + """ + _response = self._raw_client.create_tree( + id=id, + tree_name=tree_name, + tree_species=tree_species, + tree_description=tree_description, + height_in_feet=height_in_feet, + planted_date=planted_date, + request_options=request_options, + ) + return _response.data + def _make_default_async_client( timeout: typing.Optional[float], @@ -504,6 +649,162 @@ async def main() -> None: _response = await self._raw_client.get_organization(request_options=request_options) return _response.data + async def create_plant( + self, + *, + species: str, + family: str, + genus: str, + common_name: str, + watering_frequency: PlantPostWateringFrequency, + sun_exposure: PlantPostSunExposure, + planted_at: typing.Optional[dt.date] = OMIT, + soil_type: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PlantStrict: + """ + Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + + Parameters + ---------- + species : str + The botanical species name. + + family : str + The botanical family. + + genus : str + The botanical genus. + + common_name : str + The common name of the plant. + + watering_frequency : PlantPostWateringFrequency + + sun_exposure : PlantPostSunExposure + Required sun exposure level. + + planted_at : typing.Optional[dt.date] + Date the plant was planted. + + soil_type : typing.Optional[str] + Preferred soil type. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PlantStrict + Created plant + + Examples + -------- + import asyncio + + from seed import AsyncSeedApi + + client = AsyncSeedApi() + + + async def main() -> None: + await client.create_plant( + species="species", + family="family", + genus="genus", + common_name="commonName", + watering_frequency="daily", + sun_exposure="full", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_plant( + species=species, + family=family, + genus=genus, + common_name=common_name, + watering_frequency=watering_frequency, + sun_exposure=sun_exposure, + planted_at=planted_at, + soil_type=soil_type, + request_options=request_options, + ) + return _response.data + + async def create_tree( + self, + *, + id: str, + tree_name: str, + tree_species: str, + tree_description: typing.Optional[str] = OMIT, + height_in_feet: typing.Optional[float] = OMIT, + planted_date: typing.Optional[dt.date] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> TreeRecord: + """ + Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + + Parameters + ---------- + id : str + Unique tree identifier. + + tree_name : str + Display name of the tree. + + tree_species : str + The species of tree. + + tree_description : typing.Optional[str] + A description of the tree. + + height_in_feet : typing.Optional[float] + Height of the tree in feet. + + planted_date : typing.Optional[dt.date] + Date the tree was planted. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TreeRecord + Created tree + + Examples + -------- + import asyncio + + from seed import AsyncSeedApi + + client = AsyncSeedApi() + + + async def main() -> None: + await client.create_tree( + id="id", + tree_name="treeName", + tree_species="treeSpecies", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_tree( + id=id, + tree_name=tree_name, + tree_species=tree_species, + tree_description=tree_description, + height_in_feet=height_in_feet, + planted_date=planted_date, + request_options=request_options, + ) + return _response.data + def _get_base_url(*, base_url: typing.Optional[str] = None, environment: SeedApiEnvironment) -> str: if base_url is not None: diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/raw_client.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/raw_client.py index 77122c88976e..d1d169619832 100644 --- a/seed/python-sdk/allof-inline/no-custom-config/src/seed/raw_client.py +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/raw_client.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import datetime as dt import typing from json.decoder import JSONDecodeError @@ -11,9 +12,13 @@ from .core.request_options import RequestOptions from .types.combined_entity import CombinedEntity from .types.organization import Organization +from .types.plant_post_sun_exposure import PlantPostSunExposure +from .types.plant_post_watering_frequency import PlantPostWateringFrequency +from .types.plant_strict import PlantStrict from .types.rule_create_request_execution_context import RuleCreateRequestExecutionContext from .types.rule_response import RuleResponse from .types.rule_type_search_response import RuleTypeSearchResponse +from .types.tree_record import TreeRecord from .types.user_search_response import UserSearchResponse from pydantic import ValidationError @@ -235,6 +240,168 @@ def get_organization( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def create_plant( + self, + *, + species: str, + family: str, + genus: str, + common_name: str, + watering_frequency: PlantPostWateringFrequency, + sun_exposure: PlantPostSunExposure, + planted_at: typing.Optional[dt.date] = OMIT, + soil_type: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[PlantStrict]: + """ + Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + + Parameters + ---------- + species : str + The botanical species name. + + family : str + The botanical family. + + genus : str + The botanical genus. + + common_name : str + The common name of the plant. + + watering_frequency : PlantPostWateringFrequency + + sun_exposure : PlantPostSunExposure + Required sun exposure level. + + planted_at : typing.Optional[dt.date] + Date the plant was planted. + + soil_type : typing.Optional[str] + Preferred soil type. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[PlantStrict] + Created plant + """ + _response = self._client_wrapper.httpx_client.request( + "plants", + method="POST", + json={ + "species": species, + "family": family, + "genus": genus, + "commonName": common_name, + "wateringFrequency": watering_frequency, + "sunExposure": sun_exposure, + "plantedAt": planted_at, + "soilType": soil_type, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PlantStrict, + parse_obj_as( + type_=PlantStrict, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def create_tree( + self, + *, + id: str, + tree_name: str, + tree_species: str, + tree_description: typing.Optional[str] = OMIT, + height_in_feet: typing.Optional[float] = OMIT, + planted_date: typing.Optional[dt.date] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[TreeRecord]: + """ + Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + + Parameters + ---------- + id : str + Unique tree identifier. + + tree_name : str + Display name of the tree. + + tree_species : str + The species of tree. + + tree_description : typing.Optional[str] + A description of the tree. + + height_in_feet : typing.Optional[float] + Height of the tree in feet. + + planted_date : typing.Optional[dt.date] + Date the tree was planted. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TreeRecord] + Created tree + """ + _response = self._client_wrapper.httpx_client.request( + "trees", + method="POST", + json={ + "id": id, + "treeName": tree_name, + "treeDescription": tree_description, + "treeSpecies": tree_species, + "heightInFeet": height_in_feet, + "plantedDate": planted_date, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TreeRecord, + parse_obj_as( + type_=TreeRecord, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + class AsyncRawSeedApi: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -451,3 +618,165 @@ async def get_organization( status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_plant( + self, + *, + species: str, + family: str, + genus: str, + common_name: str, + watering_frequency: PlantPostWateringFrequency, + sun_exposure: PlantPostSunExposure, + planted_at: typing.Optional[dt.date] = OMIT, + soil_type: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[PlantStrict]: + """ + Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + + Parameters + ---------- + species : str + The botanical species name. + + family : str + The botanical family. + + genus : str + The botanical genus. + + common_name : str + The common name of the plant. + + watering_frequency : PlantPostWateringFrequency + + sun_exposure : PlantPostSunExposure + Required sun exposure level. + + planted_at : typing.Optional[dt.date] + Date the plant was planted. + + soil_type : typing.Optional[str] + Preferred soil type. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[PlantStrict] + Created plant + """ + _response = await self._client_wrapper.httpx_client.request( + "plants", + method="POST", + json={ + "species": species, + "family": family, + "genus": genus, + "commonName": common_name, + "wateringFrequency": watering_frequency, + "sunExposure": sun_exposure, + "plantedAt": planted_at, + "soilType": soil_type, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PlantStrict, + parse_obj_as( + type_=PlantStrict, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_tree( + self, + *, + id: str, + tree_name: str, + tree_species: str, + tree_description: typing.Optional[str] = OMIT, + height_in_feet: typing.Optional[float] = OMIT, + planted_date: typing.Optional[dt.date] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[TreeRecord]: + """ + Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + + Parameters + ---------- + id : str + Unique tree identifier. + + tree_name : str + Display name of the tree. + + tree_species : str + The species of tree. + + tree_description : typing.Optional[str] + A description of the tree. + + height_in_feet : typing.Optional[float] + Height of the tree in feet. + + planted_date : typing.Optional[dt.date] + Date the tree was planted. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TreeRecord] + Created tree + """ + _response = await self._client_wrapper.httpx_client.request( + "trees", + method="POST", + json={ + "id": id, + "treeName": tree_name, + "treeDescription": tree_description, + "treeSpecies": tree_species, + "heightInFeet": height_in_feet, + "plantedDate": planted_date, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TreeRecord, + parse_obj_as( + type_=TreeRecord, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/__init__.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/__init__.py index 195eed8bda47..cdc9a76db4cd 100644 --- a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/__init__.py +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/__init__.py @@ -19,12 +19,21 @@ from .organization_metadata import OrganizationMetadata from .paginated_result import PaginatedResult from .paging_cursors import PagingCursors + from .plant_base import PlantBase + from .plant_base_watering_frequency import PlantBaseWateringFrequency + from .plant_post_sun_exposure import PlantPostSunExposure + from .plant_post_watering_frequency import PlantPostWateringFrequency + from .plant_strict import PlantStrict from .rule_create_request_execution_context import RuleCreateRequestExecutionContext from .rule_execution_context import RuleExecutionContext from .rule_response import RuleResponse from .rule_response_status import RuleResponseStatus from .rule_type import RuleType from .rule_type_search_response import RuleTypeSearchResponse + from .tree_base import TreeBase + from .tree_describable import TreeDescribable + from .tree_identifiable import TreeIdentifiable + from .tree_record import TreeRecord from .user import User from .user_search_response import UserSearchResponse _dynamic_imports: typing.Dict[str, str] = { @@ -41,12 +50,21 @@ "OrganizationMetadata": ".organization_metadata", "PaginatedResult": ".paginated_result", "PagingCursors": ".paging_cursors", + "PlantBase": ".plant_base", + "PlantBaseWateringFrequency": ".plant_base_watering_frequency", + "PlantPostSunExposure": ".plant_post_sun_exposure", + "PlantPostWateringFrequency": ".plant_post_watering_frequency", + "PlantStrict": ".plant_strict", "RuleCreateRequestExecutionContext": ".rule_create_request_execution_context", "RuleExecutionContext": ".rule_execution_context", "RuleResponse": ".rule_response", "RuleResponseStatus": ".rule_response_status", "RuleType": ".rule_type", "RuleTypeSearchResponse": ".rule_type_search_response", + "TreeBase": ".tree_base", + "TreeDescribable": ".tree_describable", + "TreeIdentifiable": ".tree_identifiable", + "TreeRecord": ".tree_record", "User": ".user", "UserSearchResponse": ".user_search_response", } @@ -87,12 +105,21 @@ def __dir__(): "OrganizationMetadata", "PaginatedResult", "PagingCursors", + "PlantBase", + "PlantBaseWateringFrequency", + "PlantPostSunExposure", + "PlantPostWateringFrequency", + "PlantStrict", "RuleCreateRequestExecutionContext", "RuleExecutionContext", "RuleResponse", "RuleResponseStatus", "RuleType", "RuleTypeSearchResponse", + "TreeBase", + "TreeDescribable", + "TreeIdentifiable", + "TreeRecord", "User", "UserSearchResponse", ] diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_base.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_base.py new file mode 100644 index 000000000000..28d98409ec3d --- /dev/null +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_base.py @@ -0,0 +1,46 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .plant_base_watering_frequency import PlantBaseWateringFrequency + + +class PlantBase(UniversalBaseModel): + species: str = pydantic.Field() + """ + The botanical species name. + """ + + family: str = pydantic.Field() + """ + The botanical family. + """ + + genus: str = pydantic.Field() + """ + The botanical genus. + """ + + common_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="commonName"), + pydantic.Field(alias="commonName", description="The common name of the plant."), + ] = None + watering_frequency: typing_extensions.Annotated[ + typing.Optional[PlantBaseWateringFrequency], + FieldMetadata(alias="wateringFrequency"), + pydantic.Field(alias="wateringFrequency"), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_base_watering_frequency.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_base_watering_frequency.py new file mode 100644 index 000000000000..6dae335d41f6 --- /dev/null +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_base_watering_frequency.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +PlantBaseWateringFrequency = typing.Union[typing.Literal["daily", "weekly", "biweekly", "monthly"], typing.Any] diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_post_sun_exposure.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_post_sun_exposure.py new file mode 100644 index 000000000000..c851b19c9292 --- /dev/null +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_post_sun_exposure.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +PlantPostSunExposure = typing.Union[typing.Literal["full", "partial", "shade"], typing.Any] diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_post_watering_frequency.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_post_watering_frequency.py new file mode 100644 index 000000000000..be3bb2000f00 --- /dev/null +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_post_watering_frequency.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +PlantPostWateringFrequency = typing.Union[typing.Literal["daily", "weekly", "biweekly", "monthly"], typing.Any] diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_strict.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_strict.py new file mode 100644 index 000000000000..4109c1823e60 --- /dev/null +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/plant_strict.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class PlantStrict(UniversalBaseModel): + species: str = pydantic.Field() + """ + The botanical species name. + """ + + family: str = pydantic.Field() + """ + The botanical family. + """ + + genus: str = pydantic.Field() + """ + The botanical genus. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_base.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_base.py new file mode 100644 index 000000000000..c9ea20f88d94 --- /dev/null +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_base.py @@ -0,0 +1,45 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class TreeBase(UniversalBaseModel): + id: str = pydantic.Field() + """ + Unique tree identifier. + """ + + tree_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="treeName"), + pydantic.Field(alias="treeName", description="Display name of the tree."), + ] = None + tree_description: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="treeDescription"), + pydantic.Field(alias="treeDescription", description="A description of the tree."), + ] = None + tree_species: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="treeSpecies"), + pydantic.Field(alias="treeSpecies", description="The species of tree."), + ] = None + height_in_feet: typing_extensions.Annotated[ + typing.Optional[float], + FieldMetadata(alias="heightInFeet"), + pydantic.Field(alias="heightInFeet", description="Height of the tree in feet."), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_describable.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_describable.py new file mode 100644 index 000000000000..651e62c8d3c2 --- /dev/null +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_describable.py @@ -0,0 +1,30 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class TreeDescribable(UniversalBaseModel): + tree_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="treeName"), + pydantic.Field(alias="treeName", description="Display name of the tree."), + ] = None + tree_description: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="treeDescription"), + pydantic.Field(alias="treeDescription", description="A description of the tree."), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_identifiable.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_identifiable.py new file mode 100644 index 000000000000..690068db8c6f --- /dev/null +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_identifiable.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TreeIdentifiable(UniversalBaseModel): + id: str = pydantic.Field() + """ + Unique tree identifier. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_record.py b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_record.py new file mode 100644 index 000000000000..f77c6bf61767 --- /dev/null +++ b/seed/python-sdk/allof-inline/no-custom-config/src/seed/types/tree_record.py @@ -0,0 +1,47 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class TreeRecord(UniversalBaseModel): + id: str = pydantic.Field() + """ + Unique tree identifier. + """ + + tree_name: typing_extensions.Annotated[ + str, FieldMetadata(alias="treeName"), pydantic.Field(alias="treeName", description="Display name of the tree.") + ] + tree_description: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="treeDescription"), + pydantic.Field(alias="treeDescription", description="A description of the tree."), + ] = None + tree_species: typing_extensions.Annotated[ + str, FieldMetadata(alias="treeSpecies"), pydantic.Field(alias="treeSpecies", description="The species of tree.") + ] + height_in_feet: typing_extensions.Annotated[ + typing.Optional[float], + FieldMetadata(alias="heightInFeet"), + pydantic.Field(alias="heightInFeet", description="Height of the tree in feet."), + ] = None + planted_date: typing_extensions.Annotated[ + typing.Optional[dt.date], + FieldMetadata(alias="plantedDate"), + pydantic.Field(alias="plantedDate", description="Date the tree was planted."), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/seed/python-sdk/allof-inline/no-custom-config/tests/wire/test_.py b/seed/python-sdk/allof-inline/no-custom-config/tests/wire/test_.py index 2f638bc05b10..71ca1518cb36 100644 --- a/seed/python-sdk/allof-inline/no-custom-config/tests/wire/test_.py +++ b/seed/python-sdk/allof-inline/no-custom-config/tests/wire/test_.py @@ -42,3 +42,30 @@ def test__get_organization() -> None: client = get_client(test_id) client.get_organization() verify_request_count(test_id, "GET", "/organizations", None, 1) + + +def test__create_plant() -> None: + """Test createPlant endpoint with WireMock""" + test_id = "create_plant.0" + client = get_client(test_id) + client.create_plant( + species="species", + family="family", + genus="genus", + common_name="commonName", + watering_frequency="daily", + sun_exposure="full", + ) + verify_request_count(test_id, "POST", "/plants", None, 1) + + +def test__create_tree() -> None: + """Test createTree endpoint with WireMock""" + test_id = "create_tree.0" + client = get_client(test_id) + client.create_tree( + id="id", + tree_name="treeName", + tree_species="treeSpecies", + ) + verify_request_count(test_id, "POST", "/trees", None, 1) diff --git a/seed/python-sdk/allof-inline/no-custom-config/wiremock/wiremock-mappings.json b/seed/python-sdk/allof-inline/no-custom-config/wiremock/wiremock-mappings.json index 801bfb5883a2..5e69fb389c9f 100644 --- a/seed/python-sdk/allof-inline/no-custom-config/wiremock/wiremock-mappings.json +++ b/seed/python-sdk/allof-inline/no-custom-config/wiremock/wiremock-mappings.json @@ -133,9 +133,61 @@ } }, "postServeActions": [] + }, + { + "id": "fc35a3ba-6a37-4842-99e5-f58821955c31", + "name": "Create a plant (nested allOf with $ref chain) - default", + "request": { + "urlPathTemplate": "/plants", + "method": "POST" + }, + "response": { + "status": 200, + "body": "{\n \"species\": \"species\",\n \"family\": \"family\",\n \"genus\": \"genus\"\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "fc35a3ba-6a37-4842-99e5-f58821955c31", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "b78e388b-bdbb-4fab-a683-45f8819e89ec", + "name": "Create a tree (multiple nested $ref in parent allOf) - default", + "request": { + "urlPathTemplate": "/trees", + "method": "POST" + }, + "response": { + "status": 200, + "body": "{\n \"id\": \"id\",\n \"treeName\": \"treeName\",\n \"treeDescription\": \"treeDescription\",\n \"treeSpecies\": \"treeSpecies\",\n \"heightInFeet\": 1.1,\n \"plantedDate\": \"2023-01-15\"\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "b78e388b-bdbb-4fab-a683-45f8819e89ec", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } } ], "meta": { - "total": 5 + "total": 7 } } \ No newline at end of file diff --git a/seed/python-sdk/allof/no-custom-config/.fern/metadata.json b/seed/python-sdk/allof/no-custom-config/.fern/metadata.json index 46cf3f37ec30..2a49c5f592ee 100644 --- a/seed/python-sdk/allof/no-custom-config/.fern/metadata.json +++ b/seed/python-sdk/allof/no-custom-config/.fern/metadata.json @@ -6,7 +6,8 @@ "enable_wire_tests": true }, "originGitCommit": "DUMMY", - "invokedBy": "manual", + "invokedBy": "ci", "requestedVersion": "0.0.1", + "ciProvider": "github", "sdkVersion": "0.0.1" } \ No newline at end of file diff --git a/seed/python-sdk/allof/no-custom-config/poetry.lock b/seed/python-sdk/allof/no-custom-config/poetry.lock index e2e5802b45ab..67baca5d84d2 100644 --- a/seed/python-sdk/allof/no-custom-config/poetry.lock +++ b/seed/python-sdk/allof/no-custom-config/poetry.lock @@ -662,14 +662,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/any-auth/poetry.lock b/seed/python-sdk/any-auth/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/any-auth/poetry.lock +++ b/seed/python-sdk/any-auth/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/api-wide-base-path-with-default/poetry.lock b/seed/python-sdk/api-wide-base-path-with-default/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/api-wide-base-path-with-default/poetry.lock +++ b/seed/python-sdk/api-wide-base-path-with-default/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/api-wide-base-path/poetry.lock b/seed/python-sdk/api-wide-base-path/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/api-wide-base-path/poetry.lock +++ b/seed/python-sdk/api-wide-base-path/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/audiences/poetry.lock b/seed/python-sdk/audiences/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/audiences/poetry.lock +++ b/seed/python-sdk/audiences/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/basic-auth-environment-variables/poetry.lock b/seed/python-sdk/basic-auth-environment-variables/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/basic-auth-environment-variables/poetry.lock +++ b/seed/python-sdk/basic-auth-environment-variables/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/basic-auth-pw-omitted/with-wire-tests/poetry.lock b/seed/python-sdk/basic-auth-pw-omitted/with-wire-tests/poetry.lock index e2e5802b45ab..67baca5d84d2 100644 --- a/seed/python-sdk/basic-auth-pw-omitted/with-wire-tests/poetry.lock +++ b/seed/python-sdk/basic-auth-pw-omitted/with-wire-tests/poetry.lock @@ -662,14 +662,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/basic-auth/poetry.lock b/seed/python-sdk/basic-auth/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/basic-auth/poetry.lock +++ b/seed/python-sdk/basic-auth/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/bearer-token-environment-variable/poetry.lock b/seed/python-sdk/bearer-token-environment-variable/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/bearer-token-environment-variable/poetry.lock +++ b/seed/python-sdk/bearer-token-environment-variable/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/bytes-download/poetry.lock b/seed/python-sdk/bytes-download/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/bytes-download/poetry.lock +++ b/seed/python-sdk/bytes-download/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/bytes-upload/poetry.lock b/seed/python-sdk/bytes-upload/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/bytes-upload/poetry.lock +++ b/seed/python-sdk/bytes-upload/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/circular-references-advanced/no-inheritance-for-extended-models/poetry.lock b/seed/python-sdk/circular-references-advanced/no-inheritance-for-extended-models/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/circular-references-advanced/no-inheritance-for-extended-models/poetry.lock +++ b/seed/python-sdk/circular-references-advanced/no-inheritance-for-extended-models/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/circular-references-extends/no-custom-config/poetry.lock b/seed/python-sdk/circular-references-extends/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/circular-references-extends/no-custom-config/poetry.lock +++ b/seed/python-sdk/circular-references-extends/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/circular-references/no-custom-config/poetry.lock b/seed/python-sdk/circular-references/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/circular-references/no-custom-config/poetry.lock +++ b/seed/python-sdk/circular-references/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/circular-references/no-inheritance-for-extended-models/poetry.lock b/seed/python-sdk/circular-references/no-inheritance-for-extended-models/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/circular-references/no-inheritance-for-extended-models/poetry.lock +++ b/seed/python-sdk/circular-references/no-inheritance-for-extended-models/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/cli-multi-spec-namespaced/poetry.lock b/seed/python-sdk/cli-multi-spec-namespaced/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/cli-multi-spec-namespaced/poetry.lock +++ b/seed/python-sdk/cli-multi-spec-namespaced/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/cli-multi-spec/poetry.lock b/seed/python-sdk/cli-multi-spec/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/cli-multi-spec/poetry.lock +++ b/seed/python-sdk/cli-multi-spec/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/client-side-params/poetry.lock b/seed/python-sdk/client-side-params/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/client-side-params/poetry.lock +++ b/seed/python-sdk/client-side-params/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/content-type/poetry.lock b/seed/python-sdk/content-type/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/content-type/poetry.lock +++ b/seed/python-sdk/content-type/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/cross-package-type-names/poetry.lock b/seed/python-sdk/cross-package-type-names/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/cross-package-type-names/poetry.lock +++ b/seed/python-sdk/cross-package-type-names/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/dollar-string-examples/poetry.lock b/seed/python-sdk/dollar-string-examples/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/dollar-string-examples/poetry.lock +++ b/seed/python-sdk/dollar-string-examples/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/empty-clients/poetry.lock b/seed/python-sdk/empty-clients/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/empty-clients/poetry.lock +++ b/seed/python-sdk/empty-clients/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/endpoint-security-auth/poetry.lock b/seed/python-sdk/endpoint-security-auth/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/endpoint-security-auth/poetry.lock +++ b/seed/python-sdk/endpoint-security-auth/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/enum/no-custom-config/poetry.lock b/seed/python-sdk/enum/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/enum/no-custom-config/poetry.lock +++ b/seed/python-sdk/enum/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/enum/real-enum-forward-compat/poetry.lock b/seed/python-sdk/enum/real-enum-forward-compat/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/enum/real-enum-forward-compat/poetry.lock +++ b/seed/python-sdk/enum/real-enum-forward-compat/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/enum/real-enum/poetry.lock b/seed/python-sdk/enum/real-enum/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/enum/real-enum/poetry.lock +++ b/seed/python-sdk/enum/real-enum/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/enum/strenum/poetry.lock b/seed/python-sdk/enum/strenum/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/enum/strenum/poetry.lock +++ b/seed/python-sdk/enum/strenum/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/error-property/poetry.lock b/seed/python-sdk/error-property/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/error-property/poetry.lock +++ b/seed/python-sdk/error-property/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/errors/poetry.lock b/seed/python-sdk/errors/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/errors/poetry.lock +++ b/seed/python-sdk/errors/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/examples/additional_init_exports_with_duplicates/poetry.lock b/seed/python-sdk/examples/additional_init_exports_with_duplicates/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/examples/additional_init_exports_with_duplicates/poetry.lock +++ b/seed/python-sdk/examples/additional_init_exports_with_duplicates/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/examples/client-filename/poetry.lock b/seed/python-sdk/examples/client-filename/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/examples/client-filename/poetry.lock +++ b/seed/python-sdk/examples/client-filename/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/examples/legacy-wire-tests/poetry.lock b/seed/python-sdk/examples/legacy-wire-tests/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/examples/legacy-wire-tests/poetry.lock +++ b/seed/python-sdk/examples/legacy-wire-tests/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/examples/no-custom-config/poetry.lock b/seed/python-sdk/examples/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/examples/no-custom-config/poetry.lock +++ b/seed/python-sdk/examples/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/examples/omit-fern-headers/poetry.lock b/seed/python-sdk/examples/omit-fern-headers/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/examples/omit-fern-headers/poetry.lock +++ b/seed/python-sdk/examples/omit-fern-headers/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/examples/readme/poetry.lock b/seed/python-sdk/examples/readme/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/examples/readme/poetry.lock +++ b/seed/python-sdk/examples/readme/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/additional_init_exports/poetry.lock b/seed/python-sdk/exhaustive/additional_init_exports/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/additional_init_exports/poetry.lock +++ b/seed/python-sdk/exhaustive/additional_init_exports/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/aliases_with_validation/poetry.lock b/seed/python-sdk/exhaustive/aliases_with_validation/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/aliases_with_validation/poetry.lock +++ b/seed/python-sdk/exhaustive/aliases_with_validation/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/aliases_without_validation/poetry.lock b/seed/python-sdk/exhaustive/aliases_without_validation/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/aliases_without_validation/poetry.lock +++ b/seed/python-sdk/exhaustive/aliases_without_validation/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/custom-transport/poetry.lock b/seed/python-sdk/exhaustive/custom-transport/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/custom-transport/poetry.lock +++ b/seed/python-sdk/exhaustive/custom-transport/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/datetime-milliseconds/poetry.lock b/seed/python-sdk/exhaustive/datetime-milliseconds/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/datetime-milliseconds/poetry.lock +++ b/seed/python-sdk/exhaustive/datetime-milliseconds/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/deps_with_min_python_version/poetry.lock b/seed/python-sdk/exhaustive/deps_with_min_python_version/poetry.lock index 9adfeff49ba0..859cdb285b4f 100644 --- a/seed/python-sdk/exhaustive/deps_with_min_python_version/poetry.lock +++ b/seed/python-sdk/exhaustive/deps_with_min_python_version/poetry.lock @@ -732,14 +732,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] @@ -917,19 +917,19 @@ files = [ [[package]] name = "langchain" -version = "1.3.2" +version = "1.3.4" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0.0,>=3.10.0" groups = ["dev"] files = [ - {file = "langchain-1.3.2-py3-none-any.whl", hash = "sha256:900f6b3f4ee08b9ba3cdbe667dbf42525bd6f66a4a07a7f1db26262673e41ed6"}, - {file = "langchain-1.3.2.tar.gz", hash = "sha256:ffd5f204a46b5fa1a38bf89ba3b45ca0902c02d18fa7d2a2eaeaeb1f5bf19d0a"}, + {file = "langchain-1.3.4-py3-none-any.whl", hash = "sha256:e51b05ab23d056bc6bf2d97d8c694fb92d6d5765126fef74565d007c27581672"}, + {file = "langchain-1.3.4.tar.gz", hash = "sha256:d6e0654c22848925534f5c0a706f9be481bb09a619ec60a738fbd1e5502e457a"}, ] [package.dependencies] langchain-core = ">=1.4.0,<2.0.0" -langgraph = ">=1.2.2,<1.3.0" +langgraph = ">=1.2.4,<1.3.0" pydantic = ">=2.7.4,<3.0.0" [package.extras] @@ -1008,21 +1008,21 @@ typing-extensions = ">=4.13.0,<5.0.0" [[package]] name = "langgraph" -version = "1.2.2" +version = "1.2.4" description = "Building stateful, multi-actor applications with LLMs" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "langgraph-1.2.2-py3-none-any.whl", hash = "sha256:0a851bf4ba5939c5474a2fd57e6b439b5315283e254e42943bd392c2d71a5e03"}, - {file = "langgraph-1.2.2.tar.gz", hash = "sha256:f54a98458976b3ff0774683867df125fb52d8dbedeb2441d0b0656a51331cee5"}, + {file = "langgraph-1.2.4-py3-none-any.whl", hash = "sha256:ffe3e1e31dce28907640f82525858470f293506d2b272d07ea3b3ce97974b067"}, + {file = "langgraph-1.2.4.tar.gz", hash = "sha256:5df076973a2d23efb13eceb279d1e5b46feebcbbeded0a86a2ef669abd9e4399"}, ] [package.dependencies] langchain-core = ">=1.4.0,<2" langgraph-checkpoint = ">=4.1.0,<5.0.0" langgraph-prebuilt = ">=1.1.0,<1.2.0" -langgraph-sdk = ">=0.3.0,<0.4.0" +langgraph-sdk = ">=0.4.2,<0.5.0" pydantic = ">=2.7.4" xxhash = ">=3.5.0" @@ -1060,19 +1060,22 @@ langgraph-checkpoint = ">=2.1.0,<5.0.0" [[package]] name = "langgraph-sdk" -version = "0.3.15" +version = "0.4.2" description = "SDK for interacting with LangGraph API" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "langgraph_sdk-0.3.15-py3-none-any.whl", hash = "sha256:3838773acf7456d158165385d49f48f1e856f28b56ccd99ea139a8f27004815d"}, - {file = "langgraph_sdk-0.3.15.tar.gz", hash = "sha256:29e805003d2c6e296823dd71992610976fd0428cefaa8b3304fd91f2247037de"}, + {file = "langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd"}, + {file = "langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738"}, ] [package.dependencies] httpx = ">=0.25.2" +langchain-core = ">=1.4.0,<2" +langchain-protocol = ">=0.0.15" orjson = ">=3.11.5" +websockets = ">=14,<16" [[package]] name = "langsmith" @@ -2587,73 +2590,81 @@ files = [ [[package]] name = "websockets" -version = "16.0" +version = "15.0.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false -python-versions = ">=3.10" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, - {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, - {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, - {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, - {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, - {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, - {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, - {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, - {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, - {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, - {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, - {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, - {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, - {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, - {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, - {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, - {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, - {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, - {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, - {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, - {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, - {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, - {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, - {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, - {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, - {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, - {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, - {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, - {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, - {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, - {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, - {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, - {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, - {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, - {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, - {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, - {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, - {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, - {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, - {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, - {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, - {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, - {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, - {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, - {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, - {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, - {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, - {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, - {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, - {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, - {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, - {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, - {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, - {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, - {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, - {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, - {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, - {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, - {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, - {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, - {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, + {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, + {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, + {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, + {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, + {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, + {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, + {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, + {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, + {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, + {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, + {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, + {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, ] [[package]] diff --git a/seed/python-sdk/exhaustive/eager-imports/poetry.lock b/seed/python-sdk/exhaustive/eager-imports/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/eager-imports/poetry.lock +++ b/seed/python-sdk/exhaustive/eager-imports/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/extra_dependencies/poetry.lock b/seed/python-sdk/exhaustive/extra_dependencies/poetry.lock index fde5ed44a980..037b2456f5cb 100644 --- a/seed/python-sdk/exhaustive/extra_dependencies/poetry.lock +++ b/seed/python-sdk/exhaustive/extra_dependencies/poetry.lock @@ -565,14 +565,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/extra_dev_dependencies/poetry.lock b/seed/python-sdk/exhaustive/extra_dev_dependencies/poetry.lock index e2e558ed795a..d7feaf2dd2f8 100644 --- a/seed/python-sdk/exhaustive/extra_dev_dependencies/poetry.lock +++ b/seed/python-sdk/exhaustive/extra_dev_dependencies/poetry.lock @@ -702,14 +702,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/five-second-timeout/poetry.lock b/seed/python-sdk/exhaustive/five-second-timeout/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/five-second-timeout/poetry.lock +++ b/seed/python-sdk/exhaustive/five-second-timeout/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/follow_redirects_by_default/poetry.lock b/seed/python-sdk/exhaustive/follow_redirects_by_default/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/follow_redirects_by_default/poetry.lock +++ b/seed/python-sdk/exhaustive/follow_redirects_by_default/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/import-paths/poetry.lock b/seed/python-sdk/exhaustive/import-paths/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/import-paths/poetry.lock +++ b/seed/python-sdk/exhaustive/import-paths/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/improved_imports/poetry.lock b/seed/python-sdk/exhaustive/improved_imports/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/improved_imports/poetry.lock +++ b/seed/python-sdk/exhaustive/improved_imports/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/infinite-timeout/poetry.lock b/seed/python-sdk/exhaustive/infinite-timeout/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/infinite-timeout/poetry.lock +++ b/seed/python-sdk/exhaustive/infinite-timeout/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/inline-path-params/poetry.lock b/seed/python-sdk/exhaustive/inline-path-params/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/inline-path-params/poetry.lock +++ b/seed/python-sdk/exhaustive/inline-path-params/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/inline_request_params/poetry.lock b/seed/python-sdk/exhaustive/inline_request_params/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/inline_request_params/poetry.lock +++ b/seed/python-sdk/exhaustive/inline_request_params/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/no-custom-config/poetry.lock b/seed/python-sdk/exhaustive/no-custom-config/poetry.lock index e2e5802b45ab..67baca5d84d2 100644 --- a/seed/python-sdk/exhaustive/no-custom-config/poetry.lock +++ b/seed/python-sdk/exhaustive/no-custom-config/poetry.lock @@ -662,14 +662,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/output-directory-project-root/poetry.lock b/seed/python-sdk/exhaustive/output-directory-project-root/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/output-directory-project-root/poetry.lock +++ b/seed/python-sdk/exhaustive/output-directory-project-root/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/package-path/poetry.lock b/seed/python-sdk/exhaustive/package-path/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/package-path/poetry.lock +++ b/seed/python-sdk/exhaustive/package-path/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/pydantic-extra-fields/poetry.lock b/seed/python-sdk/exhaustive/pydantic-extra-fields/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/pydantic-extra-fields/poetry.lock +++ b/seed/python-sdk/exhaustive/pydantic-extra-fields/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/pydantic-ignore-fields/poetry.lock b/seed/python-sdk/exhaustive/pydantic-ignore-fields/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/pydantic-ignore-fields/poetry.lock +++ b/seed/python-sdk/exhaustive/pydantic-ignore-fields/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/pydantic-v1-with-utils/poetry.lock b/seed/python-sdk/exhaustive/pydantic-v1-with-utils/poetry.lock index 0a9f683e5998..0c72957bcfe3 100644 --- a/seed/python-sdk/exhaustive/pydantic-v1-with-utils/poetry.lock +++ b/seed/python-sdk/exhaustive/pydantic-v1-with-utils/poetry.lock @@ -511,14 +511,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/pydantic-v1-wrapped/poetry.lock b/seed/python-sdk/exhaustive/pydantic-v1-wrapped/poetry.lock index 0a9f683e5998..0c72957bcfe3 100644 --- a/seed/python-sdk/exhaustive/pydantic-v1-wrapped/poetry.lock +++ b/seed/python-sdk/exhaustive/pydantic-v1-wrapped/poetry.lock @@ -511,14 +511,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/pydantic-v1/poetry.lock b/seed/python-sdk/exhaustive/pydantic-v1/poetry.lock index 0a9f683e5998..0c72957bcfe3 100644 --- a/seed/python-sdk/exhaustive/pydantic-v1/poetry.lock +++ b/seed/python-sdk/exhaustive/pydantic-v1/poetry.lock @@ -511,14 +511,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/pydantic-v2-wrapped/poetry.lock b/seed/python-sdk/exhaustive/pydantic-v2-wrapped/poetry.lock index ea27ecb92927..9a4066f36e62 100644 --- a/seed/python-sdk/exhaustive/pydantic-v2-wrapped/poetry.lock +++ b/seed/python-sdk/exhaustive/pydantic-v2-wrapped/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/pyproject_extras/poetry.lock b/seed/python-sdk/exhaustive/pyproject_extras/poetry.lock index 9f5de9657be3..62c9bf921fd8 100644 --- a/seed/python-sdk/exhaustive/pyproject_extras/poetry.lock +++ b/seed/python-sdk/exhaustive/pyproject_extras/poetry.lock @@ -541,14 +541,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/skip-pydantic-validation/poetry.lock b/seed/python-sdk/exhaustive/skip-pydantic-validation/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/skip-pydantic-validation/poetry.lock +++ b/seed/python-sdk/exhaustive/skip-pydantic-validation/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/union-utils/poetry.lock b/seed/python-sdk/exhaustive/union-utils/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/exhaustive/union-utils/poetry.lock +++ b/seed/python-sdk/exhaustive/union-utils/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/exhaustive/wire-tests-custom-client-name/poetry.lock b/seed/python-sdk/exhaustive/wire-tests-custom-client-name/poetry.lock index e2e5802b45ab..67baca5d84d2 100644 --- a/seed/python-sdk/exhaustive/wire-tests-custom-client-name/poetry.lock +++ b/seed/python-sdk/exhaustive/wire-tests-custom-client-name/poetry.lock @@ -662,14 +662,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/extends/poetry.lock b/seed/python-sdk/extends/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/extends/poetry.lock +++ b/seed/python-sdk/extends/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/extra-properties/poetry.lock b/seed/python-sdk/extra-properties/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/extra-properties/poetry.lock +++ b/seed/python-sdk/extra-properties/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/file-download/default-chunk-size/poetry.lock b/seed/python-sdk/file-download/default-chunk-size/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/file-download/default-chunk-size/poetry.lock +++ b/seed/python-sdk/file-download/default-chunk-size/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/file-download/no-custom-config/poetry.lock b/seed/python-sdk/file-download/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/file-download/no-custom-config/poetry.lock +++ b/seed/python-sdk/file-download/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/file-upload-openapi/poetry.lock b/seed/python-sdk/file-upload-openapi/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/file-upload-openapi/poetry.lock +++ b/seed/python-sdk/file-upload-openapi/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/file-upload/exclude_types_from_init_exports/poetry.lock b/seed/python-sdk/file-upload/exclude_types_from_init_exports/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/file-upload/exclude_types_from_init_exports/poetry.lock +++ b/seed/python-sdk/file-upload/exclude_types_from_init_exports/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/file-upload/no-custom-config/poetry.lock b/seed/python-sdk/file-upload/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/file-upload/no-custom-config/poetry.lock +++ b/seed/python-sdk/file-upload/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/file-upload/use_typeddict_requests/poetry.lock b/seed/python-sdk/file-upload/use_typeddict_requests/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/file-upload/use_typeddict_requests/poetry.lock +++ b/seed/python-sdk/file-upload/use_typeddict_requests/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/folders/poetry.lock b/seed/python-sdk/folders/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/folders/poetry.lock +++ b/seed/python-sdk/folders/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/header-auth-environment-variable/poetry.lock b/seed/python-sdk/header-auth-environment-variable/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/header-auth-environment-variable/poetry.lock +++ b/seed/python-sdk/header-auth-environment-variable/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/header-auth/poetry.lock b/seed/python-sdk/header-auth/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/header-auth/poetry.lock +++ b/seed/python-sdk/header-auth/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/http-head/poetry.lock b/seed/python-sdk/http-head/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/http-head/poetry.lock +++ b/seed/python-sdk/http-head/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/idempotency-headers/poetry.lock b/seed/python-sdk/idempotency-headers/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/idempotency-headers/poetry.lock +++ b/seed/python-sdk/idempotency-headers/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/imdb/poetry.lock b/seed/python-sdk/imdb/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/imdb/poetry.lock +++ b/seed/python-sdk/imdb/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/inferred-auth-explicit/poetry.lock b/seed/python-sdk/inferred-auth-explicit/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/inferred-auth-explicit/poetry.lock +++ b/seed/python-sdk/inferred-auth-explicit/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/inferred-auth-implicit-api-key/poetry.lock b/seed/python-sdk/inferred-auth-implicit-api-key/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/inferred-auth-implicit-api-key/poetry.lock +++ b/seed/python-sdk/inferred-auth-implicit-api-key/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/inferred-auth-implicit-no-expiry/poetry.lock b/seed/python-sdk/inferred-auth-implicit-no-expiry/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/inferred-auth-implicit-no-expiry/poetry.lock +++ b/seed/python-sdk/inferred-auth-implicit-no-expiry/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/inferred-auth-implicit-reference/poetry.lock b/seed/python-sdk/inferred-auth-implicit-reference/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/inferred-auth-implicit-reference/poetry.lock +++ b/seed/python-sdk/inferred-auth-implicit-reference/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/inferred-auth-implicit/poetry.lock b/seed/python-sdk/inferred-auth-implicit/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/inferred-auth-implicit/poetry.lock +++ b/seed/python-sdk/inferred-auth-implicit/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/license/poetry.lock b/seed/python-sdk/license/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/license/poetry.lock +++ b/seed/python-sdk/license/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/literal-user-agent/no-custom-config/poetry.lock b/seed/python-sdk/literal-user-agent/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/literal-user-agent/no-custom-config/poetry.lock +++ b/seed/python-sdk/literal-user-agent/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/literal/no-custom-config/poetry.lock b/seed/python-sdk/literal/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/literal/no-custom-config/poetry.lock +++ b/seed/python-sdk/literal/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/literal/use_typeddict_requests/poetry.lock b/seed/python-sdk/literal/use_typeddict_requests/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/literal/use_typeddict_requests/poetry.lock +++ b/seed/python-sdk/literal/use_typeddict_requests/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/literals-unions/poetry.lock b/seed/python-sdk/literals-unions/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/literals-unions/poetry.lock +++ b/seed/python-sdk/literals-unions/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/mixed-case/poetry.lock b/seed/python-sdk/mixed-case/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/mixed-case/poetry.lock +++ b/seed/python-sdk/mixed-case/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/mixed-file-directory/exclude_types_from_init_exports/poetry.lock b/seed/python-sdk/mixed-file-directory/exclude_types_from_init_exports/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/mixed-file-directory/exclude_types_from_init_exports/poetry.lock +++ b/seed/python-sdk/mixed-file-directory/exclude_types_from_init_exports/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/mixed-file-directory/no-custom-config/poetry.lock b/seed/python-sdk/mixed-file-directory/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/mixed-file-directory/no-custom-config/poetry.lock +++ b/seed/python-sdk/mixed-file-directory/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/multi-line-docs/poetry.lock b/seed/python-sdk/multi-line-docs/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/multi-line-docs/poetry.lock +++ b/seed/python-sdk/multi-line-docs/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/multi-url-environment-no-default/poetry.lock b/seed/python-sdk/multi-url-environment-no-default/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/multi-url-environment-no-default/poetry.lock +++ b/seed/python-sdk/multi-url-environment-no-default/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/multi-url-environment-reference/poetry.lock b/seed/python-sdk/multi-url-environment-reference/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/multi-url-environment-reference/poetry.lock +++ b/seed/python-sdk/multi-url-environment-reference/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/multi-url-environment/poetry.lock b/seed/python-sdk/multi-url-environment/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/multi-url-environment/poetry.lock +++ b/seed/python-sdk/multi-url-environment/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/multiple-request-bodies/poetry.lock b/seed/python-sdk/multiple-request-bodies/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/multiple-request-bodies/poetry.lock +++ b/seed/python-sdk/multiple-request-bodies/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/no-content-response/poetry.lock b/seed/python-sdk/no-content-response/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/no-content-response/poetry.lock +++ b/seed/python-sdk/no-content-response/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/no-environment/poetry.lock b/seed/python-sdk/no-environment/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/no-environment/poetry.lock +++ b/seed/python-sdk/no-environment/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/no-retries/poetry.lock b/seed/python-sdk/no-retries/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/no-retries/poetry.lock +++ b/seed/python-sdk/no-retries/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/null-type/poetry.lock b/seed/python-sdk/null-type/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/null-type/poetry.lock +++ b/seed/python-sdk/null-type/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/nullable-allof-extends/poetry.lock b/seed/python-sdk/nullable-allof-extends/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/nullable-allof-extends/poetry.lock +++ b/seed/python-sdk/nullable-allof-extends/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/nullable-optional/poetry.lock b/seed/python-sdk/nullable-optional/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/nullable-optional/poetry.lock +++ b/seed/python-sdk/nullable-optional/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/nullable-request-body/poetry.lock b/seed/python-sdk/nullable-request-body/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/nullable-request-body/poetry.lock +++ b/seed/python-sdk/nullable-request-body/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/nullable/no-custom-config/poetry.lock b/seed/python-sdk/nullable/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/nullable/no-custom-config/poetry.lock +++ b/seed/python-sdk/nullable/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/nullable/use-typeddict-requests/poetry.lock b/seed/python-sdk/nullable/use-typeddict-requests/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/nullable/use-typeddict-requests/poetry.lock +++ b/seed/python-sdk/nullable/use-typeddict-requests/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/oauth-client-credentials-custom/poetry.lock b/seed/python-sdk/oauth-client-credentials-custom/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/oauth-client-credentials-custom/poetry.lock +++ b/seed/python-sdk/oauth-client-credentials-custom/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/oauth-client-credentials-default/poetry.lock b/seed/python-sdk/oauth-client-credentials-default/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/oauth-client-credentials-default/poetry.lock +++ b/seed/python-sdk/oauth-client-credentials-default/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/oauth-client-credentials-environment-variables/poetry.lock b/seed/python-sdk/oauth-client-credentials-environment-variables/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/oauth-client-credentials-environment-variables/poetry.lock +++ b/seed/python-sdk/oauth-client-credentials-environment-variables/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/oauth-client-credentials-mandatory-auth/no-custom-config/poetry.lock b/seed/python-sdk/oauth-client-credentials-mandatory-auth/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/oauth-client-credentials-mandatory-auth/no-custom-config/poetry.lock +++ b/seed/python-sdk/oauth-client-credentials-mandatory-auth/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/oauth-client-credentials-nested-root/poetry.lock b/seed/python-sdk/oauth-client-credentials-nested-root/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/oauth-client-credentials-nested-root/poetry.lock +++ b/seed/python-sdk/oauth-client-credentials-nested-root/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/oauth-client-credentials-openapi/poetry.lock b/seed/python-sdk/oauth-client-credentials-openapi/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/oauth-client-credentials-openapi/poetry.lock +++ b/seed/python-sdk/oauth-client-credentials-openapi/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/oauth-client-credentials-reference/poetry.lock b/seed/python-sdk/oauth-client-credentials-reference/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/oauth-client-credentials-reference/poetry.lock +++ b/seed/python-sdk/oauth-client-credentials-reference/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/oauth-client-credentials-with-variables/poetry.lock b/seed/python-sdk/oauth-client-credentials-with-variables/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/oauth-client-credentials-with-variables/poetry.lock +++ b/seed/python-sdk/oauth-client-credentials-with-variables/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/oauth-client-credentials/poetry.lock b/seed/python-sdk/oauth-client-credentials/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/oauth-client-credentials/poetry.lock +++ b/seed/python-sdk/oauth-client-credentials/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/object/poetry.lock b/seed/python-sdk/object/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/object/poetry.lock +++ b/seed/python-sdk/object/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/objects-with-imports/poetry.lock b/seed/python-sdk/objects-with-imports/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/objects-with-imports/poetry.lock +++ b/seed/python-sdk/objects-with-imports/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/openapi-request-body-ref/poetry.lock b/seed/python-sdk/openapi-request-body-ref/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/openapi-request-body-ref/poetry.lock +++ b/seed/python-sdk/openapi-request-body-ref/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/optional/poetry.lock b/seed/python-sdk/optional/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/optional/poetry.lock +++ b/seed/python-sdk/optional/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/package-yml/poetry.lock b/seed/python-sdk/package-yml/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/package-yml/poetry.lock +++ b/seed/python-sdk/package-yml/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/pagination-custom/poetry.lock b/seed/python-sdk/pagination-custom/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/pagination-custom/poetry.lock +++ b/seed/python-sdk/pagination-custom/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/pagination-uri-path/poetry.lock b/seed/python-sdk/pagination-uri-path/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/pagination-uri-path/poetry.lock +++ b/seed/python-sdk/pagination-uri-path/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/pagination/no-custom-config/poetry.lock b/seed/python-sdk/pagination/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/pagination/no-custom-config/poetry.lock +++ b/seed/python-sdk/pagination/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/pagination/no-inheritance-for-extended-models/poetry.lock b/seed/python-sdk/pagination/no-inheritance-for-extended-models/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/pagination/no-inheritance-for-extended-models/poetry.lock +++ b/seed/python-sdk/pagination/no-inheritance-for-extended-models/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/pagination/page-index-semantics/poetry.lock b/seed/python-sdk/pagination/page-index-semantics/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/pagination/page-index-semantics/poetry.lock +++ b/seed/python-sdk/pagination/page-index-semantics/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/path-parameters/poetry.lock b/seed/python-sdk/path-parameters/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/path-parameters/poetry.lock +++ b/seed/python-sdk/path-parameters/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/plain-text/poetry.lock b/seed/python-sdk/plain-text/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/plain-text/poetry.lock +++ b/seed/python-sdk/plain-text/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/property-access/poetry.lock b/seed/python-sdk/property-access/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/property-access/poetry.lock +++ b/seed/python-sdk/property-access/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/public-object/poetry.lock b/seed/python-sdk/public-object/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/public-object/poetry.lock +++ b/seed/python-sdk/public-object/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/python-backslash-escape/poetry.lock b/seed/python-sdk/python-backslash-escape/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/python-backslash-escape/poetry.lock +++ b/seed/python-sdk/python-backslash-escape/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/python-mypy-exclude/no-custom-config/poetry.lock b/seed/python-sdk/python-mypy-exclude/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/python-mypy-exclude/no-custom-config/poetry.lock +++ b/seed/python-sdk/python-mypy-exclude/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/python-mypy-exclude/with-mypy-exclude/poetry.lock b/seed/python-sdk/python-mypy-exclude/with-mypy-exclude/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/python-mypy-exclude/with-mypy-exclude/poetry.lock +++ b/seed/python-sdk/python-mypy-exclude/with-mypy-exclude/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/python-positional-single-property/no-custom-config/poetry.lock b/seed/python-sdk/python-positional-single-property/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/python-positional-single-property/no-custom-config/poetry.lock +++ b/seed/python-sdk/python-positional-single-property/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/python-positional-single-property/with-positional-constructors/poetry.lock b/seed/python-sdk/python-positional-single-property/with-positional-constructors/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/python-positional-single-property/with-positional-constructors/poetry.lock +++ b/seed/python-sdk/python-positional-single-property/with-positional-constructors/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/python-reserved-keyword-subpackages/poetry.lock b/seed/python-sdk/python-reserved-keyword-subpackages/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/python-reserved-keyword-subpackages/poetry.lock +++ b/seed/python-sdk/python-reserved-keyword-subpackages/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/python-streaming-parameter-openapi/with-wire-tests/poetry.lock b/seed/python-sdk/python-streaming-parameter-openapi/with-wire-tests/poetry.lock index e2e5802b45ab..67baca5d84d2 100644 --- a/seed/python-sdk/python-streaming-parameter-openapi/with-wire-tests/poetry.lock +++ b/seed/python-sdk/python-streaming-parameter-openapi/with-wire-tests/poetry.lock @@ -662,14 +662,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/query-param-name-conflict/poetry.lock b/seed/python-sdk/query-param-name-conflict/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/query-param-name-conflict/poetry.lock +++ b/seed/python-sdk/query-param-name-conflict/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/query-parameters-openapi-as-objects/no-custom-config/poetry.lock b/seed/python-sdk/query-parameters-openapi-as-objects/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/query-parameters-openapi-as-objects/no-custom-config/poetry.lock +++ b/seed/python-sdk/query-parameters-openapi-as-objects/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/query-parameters-openapi/no-custom-config/poetry.lock b/seed/python-sdk/query-parameters-openapi/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/query-parameters-openapi/no-custom-config/poetry.lock +++ b/seed/python-sdk/query-parameters-openapi/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/query-parameters/no-custom-config/poetry.lock b/seed/python-sdk/query-parameters/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/query-parameters/no-custom-config/poetry.lock +++ b/seed/python-sdk/query-parameters/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/request-parameters/poetry.lock b/seed/python-sdk/request-parameters/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/request-parameters/poetry.lock +++ b/seed/python-sdk/request-parameters/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/required-nullable/poetry.lock b/seed/python-sdk/required-nullable/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/required-nullable/poetry.lock +++ b/seed/python-sdk/required-nullable/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/reserved-keywords/poetry.lock b/seed/python-sdk/reserved-keywords/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/reserved-keywords/poetry.lock +++ b/seed/python-sdk/reserved-keywords/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/response-property/poetry.lock b/seed/python-sdk/response-property/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/response-property/poetry.lock +++ b/seed/python-sdk/response-property/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/schemaless-request-body-examples/poetry.lock b/seed/python-sdk/schemaless-request-body-examples/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/schemaless-request-body-examples/poetry.lock +++ b/seed/python-sdk/schemaless-request-body-examples/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/server-sent-event-examples/poetry.lock b/seed/python-sdk/server-sent-event-examples/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/server-sent-event-examples/poetry.lock +++ b/seed/python-sdk/server-sent-event-examples/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/server-sent-events-openapi/with-wire-tests/poetry.lock b/seed/python-sdk/server-sent-events-openapi/with-wire-tests/poetry.lock index e2e5802b45ab..67baca5d84d2 100644 --- a/seed/python-sdk/server-sent-events-openapi/with-wire-tests/poetry.lock +++ b/seed/python-sdk/server-sent-events-openapi/with-wire-tests/poetry.lock @@ -662,14 +662,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/server-sent-events-resumable/poetry.lock b/seed/python-sdk/server-sent-events-resumable/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/server-sent-events-resumable/poetry.lock +++ b/seed/python-sdk/server-sent-events-resumable/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/server-sent-events/with-wire-tests/poetry.lock b/seed/python-sdk/server-sent-events/with-wire-tests/poetry.lock index e2e5802b45ab..67baca5d84d2 100644 --- a/seed/python-sdk/server-sent-events/with-wire-tests/poetry.lock +++ b/seed/python-sdk/server-sent-events/with-wire-tests/poetry.lock @@ -662,14 +662,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/server-url-templating/no-custom-config/poetry.lock b/seed/python-sdk/server-url-templating/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/server-url-templating/no-custom-config/poetry.lock +++ b/seed/python-sdk/server-url-templating/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/simple-api/poetry.lock b/seed/python-sdk/simple-api/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/simple-api/poetry.lock +++ b/seed/python-sdk/simple-api/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/simple-fhir/no-inheritance-for-extended-models/poetry.lock b/seed/python-sdk/simple-fhir/no-inheritance-for-extended-models/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/simple-fhir/no-inheritance-for-extended-models/poetry.lock +++ b/seed/python-sdk/simple-fhir/no-inheritance-for-extended-models/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/single-url-environment-default/poetry.lock b/seed/python-sdk/single-url-environment-default/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/single-url-environment-default/poetry.lock +++ b/seed/python-sdk/single-url-environment-default/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/single-url-environment-no-default/poetry.lock b/seed/python-sdk/single-url-environment-no-default/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/single-url-environment-no-default/poetry.lock +++ b/seed/python-sdk/single-url-environment-no-default/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/streaming-parameter/poetry.lock b/seed/python-sdk/streaming-parameter/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/streaming-parameter/poetry.lock +++ b/seed/python-sdk/streaming-parameter/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/streaming/no-custom-config/poetry.lock b/seed/python-sdk/streaming/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/streaming/no-custom-config/poetry.lock +++ b/seed/python-sdk/streaming/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/streaming/skip-pydantic-validation/poetry.lock b/seed/python-sdk/streaming/skip-pydantic-validation/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/streaming/skip-pydantic-validation/poetry.lock +++ b/seed/python-sdk/streaming/skip-pydantic-validation/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/trace/poetry.lock b/seed/python-sdk/trace/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/trace/poetry.lock +++ b/seed/python-sdk/trace/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/undiscriminated-union-with-response-property/poetry.lock b/seed/python-sdk/undiscriminated-union-with-response-property/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/undiscriminated-union-with-response-property/poetry.lock +++ b/seed/python-sdk/undiscriminated-union-with-response-property/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/undiscriminated-unions/poetry.lock b/seed/python-sdk/undiscriminated-unions/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/undiscriminated-unions/poetry.lock +++ b/seed/python-sdk/undiscriminated-unions/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/union-query-parameters/poetry.lock b/seed/python-sdk/union-query-parameters/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/union-query-parameters/poetry.lock +++ b/seed/python-sdk/union-query-parameters/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/unions-with-local-date/poetry.lock b/seed/python-sdk/unions-with-local-date/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/unions-with-local-date/poetry.lock +++ b/seed/python-sdk/unions-with-local-date/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/unions/flatten-union-request-bodies/poetry.lock b/seed/python-sdk/unions/flatten-union-request-bodies/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/unions/flatten-union-request-bodies/poetry.lock +++ b/seed/python-sdk/unions/flatten-union-request-bodies/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/unions/no-custom-config/poetry.lock b/seed/python-sdk/unions/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/unions/no-custom-config/poetry.lock +++ b/seed/python-sdk/unions/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/unions/union-naming-v1-wire-tests/poetry.lock b/seed/python-sdk/unions/union-naming-v1-wire-tests/poetry.lock index e2e5802b45ab..67baca5d84d2 100644 --- a/seed/python-sdk/unions/union-naming-v1-wire-tests/poetry.lock +++ b/seed/python-sdk/unions/union-naming-v1-wire-tests/poetry.lock @@ -662,14 +662,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/unions/union-naming-v1/poetry.lock b/seed/python-sdk/unions/union-naming-v1/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/unions/union-naming-v1/poetry.lock +++ b/seed/python-sdk/unions/union-naming-v1/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/unions/union-utils/poetry.lock b/seed/python-sdk/unions/union-utils/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/unions/union-utils/poetry.lock +++ b/seed/python-sdk/unions/union-utils/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/unknown/poetry.lock b/seed/python-sdk/unknown/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/unknown/poetry.lock +++ b/seed/python-sdk/unknown/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/url-form-encoded/poetry.lock b/seed/python-sdk/url-form-encoded/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/url-form-encoded/poetry.lock +++ b/seed/python-sdk/url-form-encoded/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/validation/no-custom-config/poetry.lock b/seed/python-sdk/validation/no-custom-config/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/validation/no-custom-config/poetry.lock +++ b/seed/python-sdk/validation/no-custom-config/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/validation/with-defaults-parameters/poetry.lock b/seed/python-sdk/validation/with-defaults-parameters/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/validation/with-defaults-parameters/poetry.lock +++ b/seed/python-sdk/validation/with-defaults-parameters/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/validation/with-defaults/poetry.lock b/seed/python-sdk/validation/with-defaults/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/validation/with-defaults/poetry.lock +++ b/seed/python-sdk/validation/with-defaults/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/variables/poetry.lock b/seed/python-sdk/variables/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/variables/poetry.lock +++ b/seed/python-sdk/variables/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/version-no-default/poetry.lock b/seed/python-sdk/version-no-default/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/version-no-default/poetry.lock +++ b/seed/python-sdk/version-no-default/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/version/poetry.lock b/seed/python-sdk/version/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/version/poetry.lock +++ b/seed/python-sdk/version/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/webhook-audience/poetry.lock b/seed/python-sdk/webhook-audience/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/webhook-audience/poetry.lock +++ b/seed/python-sdk/webhook-audience/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/webhooks/poetry.lock b/seed/python-sdk/webhooks/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/webhooks/poetry.lock +++ b/seed/python-sdk/webhooks/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/websocket-bearer-auth/poetry.lock b/seed/python-sdk/websocket-bearer-auth/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/websocket-bearer-auth/poetry.lock +++ b/seed/python-sdk/websocket-bearer-auth/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/websocket-inferred-auth/poetry.lock b/seed/python-sdk/websocket-inferred-auth/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/websocket-inferred-auth/poetry.lock +++ b/seed/python-sdk/websocket-inferred-auth/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/websocket-multi-url/poetry.lock b/seed/python-sdk/websocket-multi-url/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/websocket-multi-url/poetry.lock +++ b/seed/python-sdk/websocket-multi-url/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/websocket/websocket-base/poetry.lock b/seed/python-sdk/websocket/websocket-base/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/websocket/websocket-base/poetry.lock +++ b/seed/python-sdk/websocket/websocket-base/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/websocket/websocket-with_generated_clients-skip_validation/poetry.lock b/seed/python-sdk/websocket/websocket-with_generated_clients-skip_validation/poetry.lock index 236d1dae25c0..fe90667d4a8c 100644 --- a/seed/python-sdk/websocket/websocket-with_generated_clients-skip_validation/poetry.lock +++ b/seed/python-sdk/websocket/websocket-with_generated_clients-skip_validation/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/websocket/websocket-with_generated_clients/poetry.lock b/seed/python-sdk/websocket/websocket-with_generated_clients/poetry.lock index 236d1dae25c0..fe90667d4a8c 100644 --- a/seed/python-sdk/websocket/websocket-with_generated_clients/poetry.lock +++ b/seed/python-sdk/websocket/websocket-with_generated_clients/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/python-sdk/x-fern-default/poetry.lock b/seed/python-sdk/x-fern-default/poetry.lock index f081ee955c65..72269479d370 100644 --- a/seed/python-sdk/x-fern-default/poetry.lock +++ b/seed/python-sdk/x-fern-default/poetry.lock @@ -523,14 +523,14 @@ httpx = ">=0.27.0" [[package]] name = "idna" -version = "3.17" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"}, - {file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] diff --git a/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example10/snippet.rb b/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example10/snippet.rb new file mode 100644 index 000000000000..abdbdaf9c753 --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example10/snippet.rb @@ -0,0 +1,12 @@ +require "seed" + +client = Seed::Client.new(base_url: "https://api.fern.com") + +client.create_plant( + species: "species", + family: "family", + genus: "genus", + common_name: "commonName", + watering_frequency: "daily", + sun_exposure: "full" +) diff --git a/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example11/snippet.rb b/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example11/snippet.rb new file mode 100644 index 000000000000..cc3ec03b691c --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example11/snippet.rb @@ -0,0 +1,14 @@ +require "seed" + +client = Seed::Client.new(base_url: "https://api.fern.com") + +client.create_plant( + species: "species", + family: "family", + genus: "genus", + common_name: "commonName", + watering_frequency: "daily", + sun_exposure: "full", + planted_at: "2023-01-15", + soil_type: "soilType" +) diff --git a/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example12/snippet.rb b/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example12/snippet.rb new file mode 100644 index 000000000000..b72eb875d6ad --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example12/snippet.rb @@ -0,0 +1,9 @@ +require "seed" + +client = Seed::Client.new(base_url: "https://api.fern.com") + +client.create_tree( + id: "id", + tree_name: "treeName", + tree_species: "treeSpecies" +) diff --git a/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example13/snippet.rb b/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example13/snippet.rb new file mode 100644 index 000000000000..898f0145634c --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/dynamic-snippets/example13/snippet.rb @@ -0,0 +1,12 @@ +require "seed" + +client = Seed::Client.new(base_url: "https://api.fern.com") + +client.create_tree( + id: "id", + tree_name: "treeName", + tree_description: "treeDescription", + tree_species: "treeSpecies", + height_in_feet: 1.1, + planted_date: "2023-01-15" +) diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed.rb index 5176ea30bfe8..36f084ee6b50 100644 --- a/seed/ruby-sdk-v2/allof-inline/lib/seed.rb +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed.rb @@ -36,6 +36,8 @@ require_relative "seed/internal/iterators/cursor_page_iterator" require_relative "seed/internal/iterators/offset_page_iterator" require_relative "seed/types/rule_create_request_execution_context" +require_relative "seed/types/plant_post_watering_frequency" +require_relative "seed/types/plant_post_sun_exposure" require_relative "seed/types/paging_cursors" require_relative "seed/types/paginated_result" require_relative "seed/types/rule_execution_context" @@ -56,7 +58,15 @@ require_relative "seed/types/detailed_org" require_relative "seed/types/organization_metadata" require_relative "seed/types/organization" +require_relative "seed/types/plant_strict" +require_relative "seed/types/plant_base_watering_frequency" +require_relative "seed/types/plant_base" +require_relative "seed/types/tree_identifiable" +require_relative "seed/types/tree_describable" +require_relative "seed/types/tree_base" +require_relative "seed/types/tree_record" require_relative "seed/client" require_relative "seed/types/search_rule_types_request" require_relative "seed/types/rule_create_request" +require_relative "seed/types/plant_post" require_relative "seed/environment" diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/client.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/client.rb index ab633b3a06a5..40181cdd1e0e 100644 --- a/seed/ruby-sdk-v2/allof-inline/lib/seed/client.rb +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/client.rb @@ -160,6 +160,76 @@ def get_organization(request_options: {}, **_params) end end + # Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's + # properties must be resolved through the nested $ref. + # + # @param request_options [Hash] + # @param params [Seed::Types::PlantPost] + # @option request_options [String] :base_url + # @option request_options [Hash{String => Object}] :additional_headers + # @option request_options [Hash{String => Object}] :additional_query_parameters + # @option request_options [Hash{String => Object}] :additional_body_parameters + # @option request_options [Integer] :timeout_in_seconds + # + # @return [Seed::Types::PlantStrict] + def create_plant(request_options: {}, **params) + params = Seed::Internal::Types::Utils.normalize_keys(params) + request = Seed::Internal::JSON::Request.new( + base_url: request_options[:base_url], + method: "POST", + path: "plants", + body: Seed::Types::PlantPost.new(params).to_h, + request_options: request_options + ) + begin + response = @client.send(request) + rescue Net::HTTPRequestTimeout + raise Seed::Errors::TimeoutError + end + code = response.code.to_i + if code.between?(200, 299) + Seed::Types::PlantStrict.load(response.body) + else + error_class = Seed::Errors::ResponseError.subclass_for_code(code) + raise error_class.new(response.body, code: code) + end + end + + # Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties + # merged. + # + # @param request_options [Hash] + # @param params [Seed::Types::TreeRecord] + # @option request_options [String] :base_url + # @option request_options [Hash{String => Object}] :additional_headers + # @option request_options [Hash{String => Object}] :additional_query_parameters + # @option request_options [Hash{String => Object}] :additional_body_parameters + # @option request_options [Integer] :timeout_in_seconds + # + # @return [Seed::Types::TreeRecord] + def create_tree(request_options: {}, **params) + params = Seed::Internal::Types::Utils.normalize_keys(params) + request = Seed::Internal::JSON::Request.new( + base_url: request_options[:base_url], + method: "POST", + path: "trees", + body: Seed::Types::TreeRecord.new(params).to_h, + request_options: request_options + ) + begin + response = @client.send(request) + rescue Net::HTTPRequestTimeout + raise Seed::Errors::TimeoutError + end + code = response.code.to_i + if code.between?(200, 299) + Seed::Types::TreeRecord.load(response.body) + else + error_class = Seed::Errors::ResponseError.subclass_for_code(code) + raise error_class.new(response.body, code: code) + end + end + # @param base_url [String, nil] # @param max_retries [Integer] # diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_base.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_base.rb new file mode 100644 index 000000000000..1107f68644ea --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Seed + module Types + class PlantBase < Internal::Types::Model + field :species, -> { String }, optional: false, nullable: false + + field :family, -> { String }, optional: false, nullable: false + + field :genus, -> { String }, optional: false, nullable: false + + field :common_name, -> { String }, optional: true, nullable: false, api_name: "commonName" + + field :watering_frequency, -> { Seed::Types::PlantBaseWateringFrequency }, optional: true, nullable: false, api_name: "wateringFrequency" + end + end +end diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_base_watering_frequency.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_base_watering_frequency.rb new file mode 100644 index 000000000000..4b74a0ed2b7e --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_base_watering_frequency.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Seed + module Types + module PlantBaseWateringFrequency + extend Seed::Internal::Types::Enum + + DAILY = "daily" + WEEKLY = "weekly" + BIWEEKLY = "biweekly" + MONTHLY = "monthly" + end + end +end diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post.rb new file mode 100644 index 000000000000..f695e46ce382 --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Seed + module Types + class PlantPost < Internal::Types::Model + field :species, -> { String }, optional: false, nullable: false + + field :family, -> { String }, optional: false, nullable: false + + field :genus, -> { String }, optional: false, nullable: false + + field :common_name, -> { String }, optional: false, nullable: false, api_name: "commonName" + + field :watering_frequency, -> { Seed::Types::PlantPostWateringFrequency }, optional: false, nullable: false, api_name: "wateringFrequency" + + field :sun_exposure, -> { Seed::Types::PlantPostSunExposure }, optional: false, nullable: false, api_name: "sunExposure" + + field :planted_at, -> { String }, optional: true, nullable: false, api_name: "plantedAt" + + field :soil_type, -> { String }, optional: true, nullable: false, api_name: "soilType" + end + end +end diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post_sun_exposure.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post_sun_exposure.rb new file mode 100644 index 000000000000..3cb190773f30 --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post_sun_exposure.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Seed + module Types + module PlantPostSunExposure + extend Seed::Internal::Types::Enum + + FULL = "full" + PARTIAL = "partial" + SHADE = "shade" + end + end +end diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post_watering_frequency.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post_watering_frequency.rb new file mode 100644 index 000000000000..e853910db677 --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_post_watering_frequency.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Seed + module Types + module PlantPostWateringFrequency + extend Seed::Internal::Types::Enum + + DAILY = "daily" + WEEKLY = "weekly" + BIWEEKLY = "biweekly" + MONTHLY = "monthly" + end + end +end diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_strict.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_strict.rb new file mode 100644 index 000000000000..464d31fdd3a6 --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/plant_strict.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Seed + module Types + class PlantStrict < Internal::Types::Model + field :species, -> { String }, optional: false, nullable: false + + field :family, -> { String }, optional: false, nullable: false + + field :genus, -> { String }, optional: false, nullable: false + end + end +end diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_base.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_base.rb new file mode 100644 index 000000000000..3bc9a03d95c6 --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Seed + module Types + class TreeBase < Internal::Types::Model + field :id, -> { String }, optional: false, nullable: false + + field :tree_name, -> { String }, optional: true, nullable: false, api_name: "treeName" + + field :tree_description, -> { String }, optional: true, nullable: false, api_name: "treeDescription" + + field :tree_species, -> { String }, optional: true, nullable: false, api_name: "treeSpecies" + + field :height_in_feet, -> { Integer }, optional: true, nullable: false, api_name: "heightInFeet" + end + end +end diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_describable.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_describable.rb new file mode 100644 index 000000000000..b5d07ed3003f --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_describable.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Seed + module Types + class TreeDescribable < Internal::Types::Model + field :tree_name, -> { String }, optional: true, nullable: false, api_name: "treeName" + + field :tree_description, -> { String }, optional: true, nullable: false, api_name: "treeDescription" + end + end +end diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_identifiable.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_identifiable.rb new file mode 100644 index 000000000000..fb6a9e9fb6b8 --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_identifiable.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Seed + module Types + class TreeIdentifiable < Internal::Types::Model + field :id, -> { String }, optional: false, nullable: false + end + end +end diff --git a/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_record.rb b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_record.rb new file mode 100644 index 000000000000..659c6f02e506 --- /dev/null +++ b/seed/ruby-sdk-v2/allof-inline/lib/seed/types/tree_record.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Seed + module Types + class TreeRecord < Internal::Types::Model + field :id, -> { String }, optional: false, nullable: false + + field :tree_name, -> { String }, optional: false, nullable: false, api_name: "treeName" + + field :tree_description, -> { String }, optional: true, nullable: false, api_name: "treeDescription" + + field :tree_species, -> { String }, optional: false, nullable: false, api_name: "treeSpecies" + + field :height_in_feet, -> { Integer }, optional: true, nullable: false, api_name: "heightInFeet" + + field :planted_date, -> { String }, optional: true, nullable: false, api_name: "plantedDate" + end + end +end diff --git a/seed/ruby-sdk-v2/allof-inline/reference.md b/seed/ruby-sdk-v2/allof-inline/reference.md index 35e7f3dc690b..c40668821c19 100644 --- a/seed/ruby-sdk-v2/allof-inline/reference.md +++ b/seed/ruby-sdk-v2/allof-inline/reference.md @@ -226,3 +226,194 @@ client.get_organization +
client.create_plant(request) -> Seed::Types::PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```ruby +client.create_plant( + species: "species", + family: "family", + genus: "genus", + common_name: "commonName", + watering_frequency: "daily", + sun_exposure: "full" +) +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**species:** `String` — The botanical species name. + +
+
+ +
+
+ +**family:** `String` — The botanical family. + +
+
+ +
+
+ +**genus:** `String` — The botanical genus. + +
+
+ +
+
+ +**common_name:** `String` — The common name of the plant. + +
+
+ +
+
+ +**watering_frequency:** `Seed::Types::PlantPostWateringFrequency` + +
+
+ +
+
+ +**sun_exposure:** `Seed::Types::PlantPostSunExposure` — Required sun exposure level. + +
+
+ +
+
+ +**planted_at:** `String` — Date the plant was planted. + +
+
+ +
+
+ +**soil_type:** `String` — Preferred soil type. + +
+
+ +
+
+ +**request_options:** `Seed::RequestOptions` + +
+
+
+
+ + +
+
+
+ +
client.create_tree(request) -> Seed::Types::TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```ruby +client.create_tree( + id: "id", + tree_name: "treeName", + tree_species: "treeSpecies" +) +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `Seed::Types::TreeRecord` + +
+
+ +
+
+ +**request_options:** `Seed::RequestOptions` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/ruby-sdk-v2/allof/dynamic-snippets/example10/snippet.rb b/seed/ruby-sdk-v2/allof/dynamic-snippets/example10/snippet.rb new file mode 100644 index 000000000000..7bc47d566197 --- /dev/null +++ b/seed/ruby-sdk-v2/allof/dynamic-snippets/example10/snippet.rb @@ -0,0 +1,10 @@ +require "seed" + +client = Seed::Client.new(base_url: "https://api.fern.com") + +client.create_plant( + species: "species", + family: "family", + genus: "genus", + sun_exposure: "full" +) diff --git a/seed/ruby-sdk-v2/allof/dynamic-snippets/example11/snippet.rb b/seed/ruby-sdk-v2/allof/dynamic-snippets/example11/snippet.rb new file mode 100644 index 000000000000..60e6ed32782f --- /dev/null +++ b/seed/ruby-sdk-v2/allof/dynamic-snippets/example11/snippet.rb @@ -0,0 +1,14 @@ +require "seed" + +client = Seed::Client.new(base_url: "https://api.fern.com") + +client.create_plant( + common_name: "commonName", + watering_frequency: "daily", + species: "species", + family: "family", + genus: "genus", + sun_exposure: "full", + planted_at: "2023-01-15", + soil_type: "soilType" +) diff --git a/seed/ruby-sdk-v2/allof/dynamic-snippets/example12/snippet.rb b/seed/ruby-sdk-v2/allof/dynamic-snippets/example12/snippet.rb new file mode 100644 index 000000000000..d223eff48ed4 --- /dev/null +++ b/seed/ruby-sdk-v2/allof/dynamic-snippets/example12/snippet.rb @@ -0,0 +1,5 @@ +require "seed" + +client = Seed::Client.new(base_url: "https://api.fern.com") + +client.create_tree(id: "id") diff --git a/seed/ruby-sdk-v2/allof/dynamic-snippets/example13/snippet.rb b/seed/ruby-sdk-v2/allof/dynamic-snippets/example13/snippet.rb new file mode 100644 index 000000000000..24c18f38bbee --- /dev/null +++ b/seed/ruby-sdk-v2/allof/dynamic-snippets/example13/snippet.rb @@ -0,0 +1,12 @@ +require "seed" + +client = Seed::Client.new(base_url: "https://api.fern.com") + +client.create_tree( + tree_species: "treeSpecies", + height_in_feet: 1.1, + id: "id", + tree_name: "treeName", + tree_description: "treeDescription", + planted_date: "2023-01-15" +) diff --git a/seed/ruby-sdk-v2/allof/lib/seed.rb b/seed/ruby-sdk-v2/allof/lib/seed.rb index 7b68a853d797..b5301527017c 100644 --- a/seed/ruby-sdk-v2/allof/lib/seed.rb +++ b/seed/ruby-sdk-v2/allof/lib/seed.rb @@ -36,6 +36,7 @@ require_relative "seed/internal/iterators/cursor_page_iterator" require_relative "seed/internal/iterators/offset_page_iterator" require_relative "seed/types/rule_create_request_execution_context" +require_relative "seed/types/plant_post_sun_exposure" require_relative "seed/types/paging_cursors" require_relative "seed/types/paginated_result" require_relative "seed/types/rule_execution_context" @@ -55,7 +56,15 @@ require_relative "seed/types/detailed_org_metadata" require_relative "seed/types/detailed_org" require_relative "seed/types/organization" +require_relative "seed/types/plant_strict" +require_relative "seed/types/plant_base_watering_frequency" +require_relative "seed/types/plant_base" +require_relative "seed/types/tree_identifiable" +require_relative "seed/types/tree_describable" +require_relative "seed/types/tree_base" +require_relative "seed/types/tree_record" require_relative "seed/client" require_relative "seed/types/search_rule_types_request" require_relative "seed/types/rule_create_request" +require_relative "seed/types/plant_post" require_relative "seed/environment" diff --git a/seed/ruby-sdk-v2/allof/lib/seed/client.rb b/seed/ruby-sdk-v2/allof/lib/seed/client.rb index 80f643a1cd79..f68269fe38e9 100644 --- a/seed/ruby-sdk-v2/allof/lib/seed/client.rb +++ b/seed/ruby-sdk-v2/allof/lib/seed/client.rb @@ -160,6 +160,76 @@ def get_organization(request_options: {}, **_params) end end + # Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's + # properties must be resolved through the nested $ref. + # + # @param request_options [Hash] + # @param params [Seed::Types::PlantPost] + # @option request_options [String] :base_url + # @option request_options [Hash{String => Object}] :additional_headers + # @option request_options [Hash{String => Object}] :additional_query_parameters + # @option request_options [Hash{String => Object}] :additional_body_parameters + # @option request_options [Integer] :timeout_in_seconds + # + # @return [Seed::Types::PlantStrict] + def create_plant(request_options: {}, **params) + params = Seed::Internal::Types::Utils.normalize_keys(params) + request = Seed::Internal::JSON::Request.new( + base_url: request_options[:base_url], + method: "POST", + path: "plants", + body: Seed::Types::PlantPost.new(params).to_h, + request_options: request_options + ) + begin + response = @client.send(request) + rescue Net::HTTPRequestTimeout + raise Seed::Errors::TimeoutError + end + code = response.code.to_i + if code.between?(200, 299) + Seed::Types::PlantStrict.load(response.body) + else + error_class = Seed::Errors::ResponseError.subclass_for_code(code) + raise error_class.new(response.body, code: code) + end + end + + # Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties + # merged. + # + # @param request_options [Hash] + # @param params [Seed::Types::TreeRecord] + # @option request_options [String] :base_url + # @option request_options [Hash{String => Object}] :additional_headers + # @option request_options [Hash{String => Object}] :additional_query_parameters + # @option request_options [Hash{String => Object}] :additional_body_parameters + # @option request_options [Integer] :timeout_in_seconds + # + # @return [Seed::Types::TreeRecord] + def create_tree(request_options: {}, **params) + params = Seed::Internal::Types::Utils.normalize_keys(params) + request = Seed::Internal::JSON::Request.new( + base_url: request_options[:base_url], + method: "POST", + path: "trees", + body: Seed::Types::TreeRecord.new(params).to_h, + request_options: request_options + ) + begin + response = @client.send(request) + rescue Net::HTTPRequestTimeout + raise Seed::Errors::TimeoutError + end + code = response.code.to_i + if code.between?(200, 299) + Seed::Types::TreeRecord.load(response.body) + else + error_class = Seed::Errors::ResponseError.subclass_for_code(code) + raise error_class.new(response.body, code: code) + end + end + # @param base_url [String, nil] # @param max_retries [Integer] # diff --git a/seed/ruby-sdk-v2/allof/lib/seed/types/plant_base.rb b/seed/ruby-sdk-v2/allof/lib/seed/types/plant_base.rb new file mode 100644 index 000000000000..1107f68644ea --- /dev/null +++ b/seed/ruby-sdk-v2/allof/lib/seed/types/plant_base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Seed + module Types + class PlantBase < Internal::Types::Model + field :species, -> { String }, optional: false, nullable: false + + field :family, -> { String }, optional: false, nullable: false + + field :genus, -> { String }, optional: false, nullable: false + + field :common_name, -> { String }, optional: true, nullable: false, api_name: "commonName" + + field :watering_frequency, -> { Seed::Types::PlantBaseWateringFrequency }, optional: true, nullable: false, api_name: "wateringFrequency" + end + end +end diff --git a/seed/ruby-sdk-v2/allof/lib/seed/types/plant_base_watering_frequency.rb b/seed/ruby-sdk-v2/allof/lib/seed/types/plant_base_watering_frequency.rb new file mode 100644 index 000000000000..4b74a0ed2b7e --- /dev/null +++ b/seed/ruby-sdk-v2/allof/lib/seed/types/plant_base_watering_frequency.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Seed + module Types + module PlantBaseWateringFrequency + extend Seed::Internal::Types::Enum + + DAILY = "daily" + WEEKLY = "weekly" + BIWEEKLY = "biweekly" + MONTHLY = "monthly" + end + end +end diff --git a/seed/ruby-sdk-v2/allof/lib/seed/types/plant_post.rb b/seed/ruby-sdk-v2/allof/lib/seed/types/plant_post.rb new file mode 100644 index 000000000000..9dd0fff0db92 --- /dev/null +++ b/seed/ruby-sdk-v2/allof/lib/seed/types/plant_post.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Seed + module Types + class PlantPost < Internal::Types::Model + field :sun_exposure, -> { Seed::Types::PlantPostSunExposure }, optional: false, nullable: false, api_name: "sunExposure" + + field :planted_at, -> { String }, optional: true, nullable: false, api_name: "plantedAt" + + field :soil_type, -> { String }, optional: true, nullable: false, api_name: "soilType" + + field :common_name, -> { String }, optional: true, nullable: false, api_name: "commonName" + + field :watering_frequency, -> { Seed::Types::PlantBaseWateringFrequency }, optional: true, nullable: false, api_name: "wateringFrequency" + + field :species, -> { String }, optional: false, nullable: false + + field :family, -> { String }, optional: false, nullable: false + + field :genus, -> { String }, optional: false, nullable: false + end + end +end diff --git a/seed/ruby-sdk-v2/allof/lib/seed/types/plant_post_sun_exposure.rb b/seed/ruby-sdk-v2/allof/lib/seed/types/plant_post_sun_exposure.rb new file mode 100644 index 000000000000..3cb190773f30 --- /dev/null +++ b/seed/ruby-sdk-v2/allof/lib/seed/types/plant_post_sun_exposure.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Seed + module Types + module PlantPostSunExposure + extend Seed::Internal::Types::Enum + + FULL = "full" + PARTIAL = "partial" + SHADE = "shade" + end + end +end diff --git a/seed/ruby-sdk-v2/allof/lib/seed/types/plant_strict.rb b/seed/ruby-sdk-v2/allof/lib/seed/types/plant_strict.rb new file mode 100644 index 000000000000..464d31fdd3a6 --- /dev/null +++ b/seed/ruby-sdk-v2/allof/lib/seed/types/plant_strict.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Seed + module Types + class PlantStrict < Internal::Types::Model + field :species, -> { String }, optional: false, nullable: false + + field :family, -> { String }, optional: false, nullable: false + + field :genus, -> { String }, optional: false, nullable: false + end + end +end diff --git a/seed/ruby-sdk-v2/allof/lib/seed/types/tree_base.rb b/seed/ruby-sdk-v2/allof/lib/seed/types/tree_base.rb new file mode 100644 index 000000000000..3bc9a03d95c6 --- /dev/null +++ b/seed/ruby-sdk-v2/allof/lib/seed/types/tree_base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Seed + module Types + class TreeBase < Internal::Types::Model + field :id, -> { String }, optional: false, nullable: false + + field :tree_name, -> { String }, optional: true, nullable: false, api_name: "treeName" + + field :tree_description, -> { String }, optional: true, nullable: false, api_name: "treeDescription" + + field :tree_species, -> { String }, optional: true, nullable: false, api_name: "treeSpecies" + + field :height_in_feet, -> { Integer }, optional: true, nullable: false, api_name: "heightInFeet" + end + end +end diff --git a/seed/ruby-sdk-v2/allof/lib/seed/types/tree_describable.rb b/seed/ruby-sdk-v2/allof/lib/seed/types/tree_describable.rb new file mode 100644 index 000000000000..b5d07ed3003f --- /dev/null +++ b/seed/ruby-sdk-v2/allof/lib/seed/types/tree_describable.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Seed + module Types + class TreeDescribable < Internal::Types::Model + field :tree_name, -> { String }, optional: true, nullable: false, api_name: "treeName" + + field :tree_description, -> { String }, optional: true, nullable: false, api_name: "treeDescription" + end + end +end diff --git a/seed/ruby-sdk-v2/allof/lib/seed/types/tree_identifiable.rb b/seed/ruby-sdk-v2/allof/lib/seed/types/tree_identifiable.rb new file mode 100644 index 000000000000..fb6a9e9fb6b8 --- /dev/null +++ b/seed/ruby-sdk-v2/allof/lib/seed/types/tree_identifiable.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Seed + module Types + class TreeIdentifiable < Internal::Types::Model + field :id, -> { String }, optional: false, nullable: false + end + end +end diff --git a/seed/ruby-sdk-v2/allof/lib/seed/types/tree_record.rb b/seed/ruby-sdk-v2/allof/lib/seed/types/tree_record.rb new file mode 100644 index 000000000000..280cbf00553e --- /dev/null +++ b/seed/ruby-sdk-v2/allof/lib/seed/types/tree_record.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Seed + module Types + class TreeRecord < Internal::Types::Model + field :tree_species, -> { String }, optional: true, nullable: false, api_name: "treeSpecies" + + field :height_in_feet, -> { Integer }, optional: true, nullable: false, api_name: "heightInFeet" + + field :id, -> { String }, optional: false, nullable: false + + field :tree_name, -> { String }, optional: true, nullable: false, api_name: "treeName" + + field :tree_description, -> { String }, optional: true, nullable: false, api_name: "treeDescription" + + field :planted_date, -> { String }, optional: true, nullable: false, api_name: "plantedDate" + end + end +end diff --git a/seed/ruby-sdk-v2/allof/reference.md b/seed/ruby-sdk-v2/allof/reference.md index 35e7f3dc690b..28141062ada8 100644 --- a/seed/ruby-sdk-v2/allof/reference.md +++ b/seed/ruby-sdk-v2/allof/reference.md @@ -226,3 +226,148 @@ client.get_organization +
client.create_plant(request) -> Seed::Types::PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```ruby +client.create_plant( + species: "species", + family: "family", + genus: "genus", + sun_exposure: "full" +) +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**sun_exposure:** `Seed::Types::PlantPostSunExposure` — Required sun exposure level. + +
+
+ +
+
+ +**planted_at:** `String` — Date the plant was planted. + +
+
+ +
+
+ +**soil_type:** `String` — Preferred soil type. + +
+
+ +
+
+ +**request_options:** `Seed::RequestOptions` + +
+
+
+
+ + +
+
+
+ +
client.create_tree(request) -> Seed::Types::TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```ruby +client.create_tree(id: "id") +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `Seed::Types::TreeRecord` + +
+
+ +
+
+ +**request_options:** `Seed::RequestOptions` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/rust-sdk/allof-inline/dynamic-snippets/example10.rs b/seed/rust-sdk/allof-inline/dynamic-snippets/example10.rs new file mode 100644 index 000000000000..7f4d80c288f9 --- /dev/null +++ b/seed/rust-sdk/allof-inline/dynamic-snippets/example10.rs @@ -0,0 +1,25 @@ +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + base_url: "https://api.fern.com".to_string(), + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_plant( + &PlantPost { + species: "species".to_string(), + family: "family".to_string(), + genus: "genus".to_string(), + common_name: "commonName".to_string(), + watering_frequency: PlantPostWateringFrequency::Daily, + sun_exposure: PlantPostSunExposure::Full, + planted_at: None, + soil_type: None, + }, + None, + ) + .await; +} diff --git a/seed/rust-sdk/allof-inline/dynamic-snippets/example11.rs b/seed/rust-sdk/allof-inline/dynamic-snippets/example11.rs new file mode 100644 index 000000000000..3531d49b46ea --- /dev/null +++ b/seed/rust-sdk/allof-inline/dynamic-snippets/example11.rs @@ -0,0 +1,25 @@ +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + base_url: "https://api.fern.com".to_string(), + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_plant( + &PlantPost { + species: "species".to_string(), + family: "family".to_string(), + genus: "genus".to_string(), + common_name: "commonName".to_string(), + watering_frequency: PlantPostWateringFrequency::Daily, + sun_exposure: PlantPostSunExposure::Full, + planted_at: Some(NaiveDate::parse_from_str("2023-01-15", "%Y-%m-%d").unwrap()), + soil_type: Some("soilType".to_string()), + }, + None, + ) + .await; +} diff --git a/seed/rust-sdk/allof-inline/dynamic-snippets/example12.rs b/seed/rust-sdk/allof-inline/dynamic-snippets/example12.rs new file mode 100644 index 000000000000..d7b6a668c398 --- /dev/null +++ b/seed/rust-sdk/allof-inline/dynamic-snippets/example12.rs @@ -0,0 +1,21 @@ +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + base_url: "https://api.fern.com".to_string(), + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_tree( + &TreeRecord { + id: "id".to_string(), + tree_name: "treeName".to_string(), + tree_species: "treeSpecies".to_string(), + ..Default::default() + }, + None, + ) + .await; +} diff --git a/seed/rust-sdk/allof-inline/dynamic-snippets/example13.rs b/seed/rust-sdk/allof-inline/dynamic-snippets/example13.rs new file mode 100644 index 000000000000..e5e085e8dac1 --- /dev/null +++ b/seed/rust-sdk/allof-inline/dynamic-snippets/example13.rs @@ -0,0 +1,24 @@ +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + base_url: "https://api.fern.com".to_string(), + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_tree( + &TreeRecord { + id: "id".to_string(), + tree_name: "treeName".to_string(), + tree_description: Some("treeDescription".to_string()), + tree_species: "treeSpecies".to_string(), + height_in_feet: Some(1.1), + planted_date: Some(NaiveDate::parse_from_str("2023-01-15", "%Y-%m-%d").unwrap()), + ..Default::default() + }, + None, + ) + .await; +} diff --git a/seed/rust-sdk/allof-inline/reference.md b/seed/rust-sdk/allof-inline/reference.md index e0e9deb87648..c7ebcad544bc 100644 --- a/seed/rust-sdk/allof-inline/reference.md +++ b/seed/rust-sdk/allof-inline/reference.md @@ -222,3 +222,194 @@ async fn main() { +
client.create_plant(request: PlantPost) -> Result<PlantStrict, ApiError> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```rust +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_plant( + &PlantPost { + species: "species".to_string(), + family: "family".to_string(), + genus: "genus".to_string(), + common_name: "commonName".to_string(), + watering_frequency: PlantPostWateringFrequency::Daily, + sun_exposure: PlantPostSunExposure::Full, + planted_at: None, + soil_type: None, + }, + None, + ) + .await; +} +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**species:** `String` — The botanical species name. + +
+
+ +
+
+ +**family:** `String` — The botanical family. + +
+
+ +
+
+ +**genus:** `String` — The botanical genus. + +
+
+ +
+
+ +**common_name:** `String` — The common name of the plant. + +
+
+ +
+
+ +**watering_frequency:** `PlantPostWateringFrequency` + +
+
+ +
+
+ +**sun_exposure:** `PlantPostSunExposure` — Required sun exposure level. + +
+
+ +
+
+ +**planted_at:** `Option` — Date the plant was planted. + +
+
+ +
+
+ +**soil_type:** `Option` — Preferred soil type. + +
+
+
+
+ + +
+
+
+ +
client.create_tree(request: TreeRecord) -> Result<TreeRecord, ApiError> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```rust +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_tree( + &TreeRecord { + id: "id".to_string(), + tree_name: "treeName".to_string(), + tree_species: "treeSpecies".to_string(), + ..Default::default() + }, + None, + ) + .await; +} +``` +
+
+
+
+ + +
+
+
+ diff --git a/seed/rust-sdk/allof-inline/src/api/resources/mod.rs b/seed/rust-sdk/allof-inline/src/api/resources/mod.rs index 543ca7436f76..d732a700d853 100644 --- a/seed/rust-sdk/allof-inline/src/api/resources/mod.rs +++ b/seed/rust-sdk/allof-inline/src/api/resources/mod.rs @@ -79,4 +79,54 @@ impl ApiClient { .execute_request(Method::GET, "organizations", None, None, options) .await } + + /// Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + pub async fn create_plant( + &self, + request: &PlantPost, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "plants", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + pub async fn create_tree( + &self, + request: &TreeRecord, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "trees", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } } diff --git a/seed/rust-sdk/allof-inline/src/api/types/mod.rs b/seed/rust-sdk/allof-inline/src/api/types/mod.rs index e5504caad5c8..dbbbfe581e25 100644 --- a/seed/rust-sdk/allof-inline/src/api/types/mod.rs +++ b/seed/rust-sdk/allof-inline/src/api/types/mod.rs @@ -11,6 +11,12 @@ pub mod organization; pub mod organization_metadata; pub mod paginated_result; pub mod paging_cursors; +pub mod plant_base; +pub mod plant_base_watering_frequency; +pub mod plant_post; +pub mod plant_post_sun_exposure; +pub mod plant_post_watering_frequency; +pub mod plant_strict; pub mod rule_create_request; pub mod rule_create_request_execution_context; pub mod rule_execution_context; @@ -19,6 +25,10 @@ pub mod rule_response_status; pub mod rule_type; pub mod rule_type_search_response; pub mod search_rule_types_query_request; +pub mod tree_base; +pub mod tree_describable; +pub mod tree_identifiable; +pub mod tree_record; pub mod user; pub mod user_search_response; @@ -35,6 +45,12 @@ pub use organization::Organization; pub use organization_metadata::OrganizationMetadata; pub use paginated_result::PaginatedResult; pub use paging_cursors::PagingCursors; +pub use plant_base::PlantBase; +pub use plant_base_watering_frequency::PlantBaseWateringFrequency; +pub use plant_post::PlantPost; +pub use plant_post_sun_exposure::PlantPostSunExposure; +pub use plant_post_watering_frequency::PlantPostWateringFrequency; +pub use plant_strict::PlantStrict; pub use rule_create_request::RuleCreateRequest; pub use rule_create_request_execution_context::RuleCreateRequestExecutionContext; pub use rule_execution_context::RuleExecutionContext; @@ -43,5 +59,9 @@ pub use rule_response_status::RuleResponseStatus; pub use rule_type::RuleType; pub use rule_type_search_response::RuleTypeSearchResponse; pub use search_rule_types_query_request::SearchRuleTypesQueryRequest; +pub use tree_base::TreeBase; +pub use tree_describable::TreeDescribable; +pub use tree_identifiable::TreeIdentifiable; +pub use tree_record::TreeRecord; pub use user::User; pub use user_search_response::UserSearchResponse; diff --git a/seed/rust-sdk/allof-inline/src/api/types/plant_base.rs b/seed/rust-sdk/allof-inline/src/api/types/plant_base.rs new file mode 100644 index 000000000000..b867dd9de574 --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/api/types/plant_base.rs @@ -0,0 +1,85 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PlantBase { + /// The botanical species name. + #[serde(default)] + pub species: String, + /// The botanical family. + #[serde(default)] + pub family: String, + /// The botanical genus. + #[serde(default)] + pub genus: String, + /// The common name of the plant. + #[serde(rename = "commonName")] + #[serde(skip_serializing_if = "Option::is_none")] + pub common_name: Option, + #[serde(rename = "wateringFrequency")] + #[serde(skip_serializing_if = "Option::is_none")] + pub watering_frequency: Option, +} + +impl PlantBase { + pub fn builder() -> PlantBaseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PlantBaseBuilder { + species: Option, + family: Option, + genus: Option, + common_name: Option, + watering_frequency: Option, +} + +impl PlantBaseBuilder { + pub fn species(mut self, value: impl Into) -> Self { + self.species = Some(value.into()); + self + } + + pub fn family(mut self, value: impl Into) -> Self { + self.family = Some(value.into()); + self + } + + pub fn genus(mut self, value: impl Into) -> Self { + self.genus = Some(value.into()); + self + } + + pub fn common_name(mut self, value: impl Into) -> Self { + self.common_name = Some(value.into()); + self + } + + pub fn watering_frequency(mut self, value: PlantBaseWateringFrequency) -> Self { + self.watering_frequency = Some(value); + self + } + + /// Consumes the builder and constructs a [`PlantBase`]. + /// This method will fail if any of the following fields are not set: + /// - [`species`](PlantBaseBuilder::species) + /// - [`family`](PlantBaseBuilder::family) + /// - [`genus`](PlantBaseBuilder::genus) + pub fn build(self) -> Result { + Ok(PlantBase { + species: self + .species + .ok_or_else(|| BuildError::missing_field("species"))?, + family: self + .family + .ok_or_else(|| BuildError::missing_field("family"))?, + genus: self + .genus + .ok_or_else(|| BuildError::missing_field("genus"))?, + common_name: self.common_name, + watering_frequency: self.watering_frequency, + }) + } +} diff --git a/seed/rust-sdk/allof-inline/src/api/types/plant_base_watering_frequency.rs b/seed/rust-sdk/allof-inline/src/api/types/plant_base_watering_frequency.rs new file mode 100644 index 000000000000..0dfe6e7273d8 --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/api/types/plant_base_watering_frequency.rs @@ -0,0 +1,50 @@ +pub use crate::prelude::*; + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PlantBaseWateringFrequency { + Daily, + Weekly, + Biweekly, + Monthly, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for PlantBaseWateringFrequency { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Daily => serializer.serialize_str("daily"), + Self::Weekly => serializer.serialize_str("weekly"), + Self::Biweekly => serializer.serialize_str("biweekly"), + Self::Monthly => serializer.serialize_str("monthly"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for PlantBaseWateringFrequency { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "daily" => Ok(Self::Daily), + "weekly" => Ok(Self::Weekly), + "biweekly" => Ok(Self::Biweekly), + "monthly" => Ok(Self::Monthly), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for PlantBaseWateringFrequency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Daily => write!(f, "daily"), + Self::Weekly => write!(f, "weekly"), + Self::Biweekly => write!(f, "biweekly"), + Self::Monthly => write!(f, "monthly"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/seed/rust-sdk/allof-inline/src/api/types/plant_post.rs b/seed/rust-sdk/allof-inline/src/api/types/plant_post.rs new file mode 100644 index 000000000000..d7c490c41e2b --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/api/types/plant_post.rs @@ -0,0 +1,125 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct PlantPost { + /// The botanical species name. + #[serde(default)] + pub species: String, + /// The botanical family. + #[serde(default)] + pub family: String, + /// The botanical genus. + #[serde(default)] + pub genus: String, + /// The common name of the plant. + #[serde(rename = "commonName")] + #[serde(default)] + pub common_name: String, + #[serde(rename = "wateringFrequency")] + pub watering_frequency: PlantPostWateringFrequency, + /// Required sun exposure level. + #[serde(rename = "sunExposure")] + pub sun_exposure: PlantPostSunExposure, + /// Date the plant was planted. + #[serde(rename = "plantedAt")] + #[serde(skip_serializing_if = "Option::is_none")] + pub planted_at: Option, + /// Preferred soil type. + #[serde(rename = "soilType")] + #[serde(skip_serializing_if = "Option::is_none")] + pub soil_type: Option, +} + +impl PlantPost { + pub fn builder() -> PlantPostBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PlantPostBuilder { + species: Option, + family: Option, + genus: Option, + common_name: Option, + watering_frequency: Option, + sun_exposure: Option, + planted_at: Option, + soil_type: Option, +} + +impl PlantPostBuilder { + pub fn species(mut self, value: impl Into) -> Self { + self.species = Some(value.into()); + self + } + + pub fn family(mut self, value: impl Into) -> Self { + self.family = Some(value.into()); + self + } + + pub fn genus(mut self, value: impl Into) -> Self { + self.genus = Some(value.into()); + self + } + + pub fn common_name(mut self, value: impl Into) -> Self { + self.common_name = Some(value.into()); + self + } + + pub fn watering_frequency(mut self, value: PlantPostWateringFrequency) -> Self { + self.watering_frequency = Some(value); + self + } + + pub fn sun_exposure(mut self, value: PlantPostSunExposure) -> Self { + self.sun_exposure = Some(value); + self + } + + pub fn planted_at(mut self, value: NaiveDate) -> Self { + self.planted_at = Some(value); + self + } + + pub fn soil_type(mut self, value: impl Into) -> Self { + self.soil_type = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`PlantPost`]. + /// This method will fail if any of the following fields are not set: + /// - [`species`](PlantPostBuilder::species) + /// - [`family`](PlantPostBuilder::family) + /// - [`genus`](PlantPostBuilder::genus) + /// - [`common_name`](PlantPostBuilder::common_name) + /// - [`watering_frequency`](PlantPostBuilder::watering_frequency) + /// - [`sun_exposure`](PlantPostBuilder::sun_exposure) + pub fn build(self) -> Result { + Ok(PlantPost { + species: self + .species + .ok_or_else(|| BuildError::missing_field("species"))?, + family: self + .family + .ok_or_else(|| BuildError::missing_field("family"))?, + genus: self + .genus + .ok_or_else(|| BuildError::missing_field("genus"))?, + common_name: self + .common_name + .ok_or_else(|| BuildError::missing_field("common_name"))?, + watering_frequency: self + .watering_frequency + .ok_or_else(|| BuildError::missing_field("watering_frequency"))?, + sun_exposure: self + .sun_exposure + .ok_or_else(|| BuildError::missing_field("sun_exposure"))?, + planted_at: self.planted_at, + soil_type: self.soil_type, + }) + } +} diff --git a/seed/rust-sdk/allof-inline/src/api/types/plant_post_sun_exposure.rs b/seed/rust-sdk/allof-inline/src/api/types/plant_post_sun_exposure.rs new file mode 100644 index 000000000000..657b2b8d4e36 --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/api/types/plant_post_sun_exposure.rs @@ -0,0 +1,47 @@ +pub use crate::prelude::*; + +/// Required sun exposure level. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PlantPostSunExposure { + Full, + Partial, + Shade, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for PlantPostSunExposure { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Full => serializer.serialize_str("full"), + Self::Partial => serializer.serialize_str("partial"), + Self::Shade => serializer.serialize_str("shade"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for PlantPostSunExposure { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "full" => Ok(Self::Full), + "partial" => Ok(Self::Partial), + "shade" => Ok(Self::Shade), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for PlantPostSunExposure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Full => write!(f, "full"), + Self::Partial => write!(f, "partial"), + Self::Shade => write!(f, "shade"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/seed/rust-sdk/allof-inline/src/api/types/plant_post_watering_frequency.rs b/seed/rust-sdk/allof-inline/src/api/types/plant_post_watering_frequency.rs new file mode 100644 index 000000000000..4f454f51de22 --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/api/types/plant_post_watering_frequency.rs @@ -0,0 +1,50 @@ +pub use crate::prelude::*; + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PlantPostWateringFrequency { + Daily, + Weekly, + Biweekly, + Monthly, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for PlantPostWateringFrequency { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Daily => serializer.serialize_str("daily"), + Self::Weekly => serializer.serialize_str("weekly"), + Self::Biweekly => serializer.serialize_str("biweekly"), + Self::Monthly => serializer.serialize_str("monthly"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for PlantPostWateringFrequency { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "daily" => Ok(Self::Daily), + "weekly" => Ok(Self::Weekly), + "biweekly" => Ok(Self::Biweekly), + "monthly" => Ok(Self::Monthly), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for PlantPostWateringFrequency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Daily => write!(f, "daily"), + Self::Weekly => write!(f, "weekly"), + Self::Biweekly => write!(f, "biweekly"), + Self::Monthly => write!(f, "monthly"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/seed/rust-sdk/allof-inline/src/api/types/plant_strict.rs b/seed/rust-sdk/allof-inline/src/api/types/plant_strict.rs new file mode 100644 index 000000000000..8bffd2121d3d --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/api/types/plant_strict.rs @@ -0,0 +1,64 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PlantStrict { + /// The botanical species name. + #[serde(default)] + pub species: String, + /// The botanical family. + #[serde(default)] + pub family: String, + /// The botanical genus. + #[serde(default)] + pub genus: String, +} + +impl PlantStrict { + pub fn builder() -> PlantStrictBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PlantStrictBuilder { + species: Option, + family: Option, + genus: Option, +} + +impl PlantStrictBuilder { + pub fn species(mut self, value: impl Into) -> Self { + self.species = Some(value.into()); + self + } + + pub fn family(mut self, value: impl Into) -> Self { + self.family = Some(value.into()); + self + } + + pub fn genus(mut self, value: impl Into) -> Self { + self.genus = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`PlantStrict`]. + /// This method will fail if any of the following fields are not set: + /// - [`species`](PlantStrictBuilder::species) + /// - [`family`](PlantStrictBuilder::family) + /// - [`genus`](PlantStrictBuilder::genus) + pub fn build(self) -> Result { + Ok(PlantStrict { + species: self + .species + .ok_or_else(|| BuildError::missing_field("species"))?, + family: self + .family + .ok_or_else(|| BuildError::missing_field("family"))?, + genus: self + .genus + .ok_or_else(|| BuildError::missing_field("genus"))?, + }) + } +} diff --git a/seed/rust-sdk/allof-inline/src/api/types/tree_base.rs b/seed/rust-sdk/allof-inline/src/api/types/tree_base.rs new file mode 100644 index 000000000000..b03143187ed2 --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/api/types/tree_base.rs @@ -0,0 +1,82 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct TreeBase { + /// Unique tree identifier. + #[serde(default)] + pub id: String, + /// Display name of the tree. + #[serde(rename = "treeName")] + #[serde(skip_serializing_if = "Option::is_none")] + pub tree_name: Option, + /// A description of the tree. + #[serde(rename = "treeDescription")] + #[serde(skip_serializing_if = "Option::is_none")] + pub tree_description: Option, + /// The species of tree. + #[serde(rename = "treeSpecies")] + #[serde(skip_serializing_if = "Option::is_none")] + pub tree_species: Option, + /// Height of the tree in feet. + #[serde(rename = "heightInFeet")] + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[serde(with = "crate::core::number_serializers::option")] + pub height_in_feet: Option, +} + +impl TreeBase { + pub fn builder() -> TreeBaseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct TreeBaseBuilder { + id: Option, + tree_name: Option, + tree_description: Option, + tree_species: Option, + height_in_feet: Option, +} + +impl TreeBaseBuilder { + pub fn id(mut self, value: impl Into) -> Self { + self.id = Some(value.into()); + self + } + + pub fn tree_name(mut self, value: impl Into) -> Self { + self.tree_name = Some(value.into()); + self + } + + pub fn tree_description(mut self, value: impl Into) -> Self { + self.tree_description = Some(value.into()); + self + } + + pub fn tree_species(mut self, value: impl Into) -> Self { + self.tree_species = Some(value.into()); + self + } + + pub fn height_in_feet(mut self, value: f64) -> Self { + self.height_in_feet = Some(value); + self + } + + /// Consumes the builder and constructs a [`TreeBase`]. + /// This method will fail if any of the following fields are not set: + /// - [`id`](TreeBaseBuilder::id) + pub fn build(self) -> Result { + Ok(TreeBase { + id: self.id.ok_or_else(|| BuildError::missing_field("id"))?, + tree_name: self.tree_name, + tree_description: self.tree_description, + tree_species: self.tree_species, + height_in_feet: self.height_in_feet, + }) + } +} diff --git a/seed/rust-sdk/allof-inline/src/api/types/tree_describable.rs b/seed/rust-sdk/allof-inline/src/api/types/tree_describable.rs new file mode 100644 index 000000000000..56c625e44c45 --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/api/types/tree_describable.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct TreeDescribable { + /// Display name of the tree. + #[serde(rename = "treeName")] + #[serde(skip_serializing_if = "Option::is_none")] + pub tree_name: Option, + /// A description of the tree. + #[serde(rename = "treeDescription")] + #[serde(skip_serializing_if = "Option::is_none")] + pub tree_description: Option, +} + +impl TreeDescribable { + pub fn builder() -> TreeDescribableBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct TreeDescribableBuilder { + tree_name: Option, + tree_description: Option, +} + +impl TreeDescribableBuilder { + pub fn tree_name(mut self, value: impl Into) -> Self { + self.tree_name = Some(value.into()); + self + } + + pub fn tree_description(mut self, value: impl Into) -> Self { + self.tree_description = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`TreeDescribable`]. + pub fn build(self) -> Result { + Ok(TreeDescribable { + tree_name: self.tree_name, + tree_description: self.tree_description, + }) + } +} diff --git a/seed/rust-sdk/allof-inline/src/api/types/tree_identifiable.rs b/seed/rust-sdk/allof-inline/src/api/types/tree_identifiable.rs new file mode 100644 index 000000000000..c18da6e8f86c --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/api/types/tree_identifiable.rs @@ -0,0 +1,36 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct TreeIdentifiable { + /// Unique tree identifier. + #[serde(default)] + pub id: String, +} + +impl TreeIdentifiable { + pub fn builder() -> TreeIdentifiableBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct TreeIdentifiableBuilder { + id: Option, +} + +impl TreeIdentifiableBuilder { + pub fn id(mut self, value: impl Into) -> Self { + self.id = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`TreeIdentifiable`]. + /// This method will fail if any of the following fields are not set: + /// - [`id`](TreeIdentifiableBuilder::id) + pub fn build(self) -> Result { + Ok(TreeIdentifiable { + id: self.id.ok_or_else(|| BuildError::missing_field("id"))?, + }) + } +} diff --git a/seed/rust-sdk/allof-inline/src/api/types/tree_record.rs b/seed/rust-sdk/allof-inline/src/api/types/tree_record.rs new file mode 100644 index 000000000000..4abf403baea7 --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/api/types/tree_record.rs @@ -0,0 +1,99 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct TreeRecord { + /// Unique tree identifier. + #[serde(default)] + pub id: String, + /// Display name of the tree. + #[serde(rename = "treeName")] + #[serde(default)] + pub tree_name: String, + /// A description of the tree. + #[serde(rename = "treeDescription")] + #[serde(skip_serializing_if = "Option::is_none")] + pub tree_description: Option, + /// The species of tree. + #[serde(rename = "treeSpecies")] + #[serde(default)] + pub tree_species: String, + /// Height of the tree in feet. + #[serde(rename = "heightInFeet")] + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[serde(with = "crate::core::number_serializers::option")] + pub height_in_feet: Option, + /// Date the tree was planted. + #[serde(rename = "plantedDate")] + #[serde(skip_serializing_if = "Option::is_none")] + pub planted_date: Option, +} + +impl TreeRecord { + pub fn builder() -> TreeRecordBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct TreeRecordBuilder { + id: Option, + tree_name: Option, + tree_description: Option, + tree_species: Option, + height_in_feet: Option, + planted_date: Option, +} + +impl TreeRecordBuilder { + pub fn id(mut self, value: impl Into) -> Self { + self.id = Some(value.into()); + self + } + + pub fn tree_name(mut self, value: impl Into) -> Self { + self.tree_name = Some(value.into()); + self + } + + pub fn tree_description(mut self, value: impl Into) -> Self { + self.tree_description = Some(value.into()); + self + } + + pub fn tree_species(mut self, value: impl Into) -> Self { + self.tree_species = Some(value.into()); + self + } + + pub fn height_in_feet(mut self, value: f64) -> Self { + self.height_in_feet = Some(value); + self + } + + pub fn planted_date(mut self, value: NaiveDate) -> Self { + self.planted_date = Some(value); + self + } + + /// Consumes the builder and constructs a [`TreeRecord`]. + /// This method will fail if any of the following fields are not set: + /// - [`id`](TreeRecordBuilder::id) + /// - [`tree_name`](TreeRecordBuilder::tree_name) + /// - [`tree_species`](TreeRecordBuilder::tree_species) + pub fn build(self) -> Result { + Ok(TreeRecord { + id: self.id.ok_or_else(|| BuildError::missing_field("id"))?, + tree_name: self + .tree_name + .ok_or_else(|| BuildError::missing_field("tree_name"))?, + tree_description: self.tree_description, + tree_species: self + .tree_species + .ok_or_else(|| BuildError::missing_field("tree_species"))?, + height_in_feet: self.height_in_feet, + planted_date: self.planted_date, + }) + } +} diff --git a/seed/rust-sdk/allof-inline/src/core/mod.rs b/seed/rust-sdk/allof-inline/src/core/mod.rs index 3050ad197607..3681fce54715 100644 --- a/seed/rust-sdk/allof-inline/src/core/mod.rs +++ b/seed/rust-sdk/allof-inline/src/core/mod.rs @@ -2,6 +2,7 @@ pub mod flexible_datetime; mod http_client; +pub mod number_serializers; mod oauth_token_provider; pub mod pagination; mod query_parameter_builder; diff --git a/seed/rust-sdk/allof-inline/src/core/number_serializers.rs b/seed/rust-sdk/allof-inline/src/core/number_serializers.rs new file mode 100644 index 000000000000..0e16157bfe9a --- /dev/null +++ b/seed/rust-sdk/allof-inline/src/core/number_serializers.rs @@ -0,0 +1,177 @@ +//! Number serialization helpers +//! +//! This module provides serde helpers for serializing f64 values +//! that strips trailing `.0` from whole numbers (e.g., 24000.0 → 24000). +//! Some APIs reject the decimal representation for integer-valued numbers. +//! +//! Usage: +//! ```rust +//! use serde::{Deserialize, Serialize}; +//! +//! #[derive(Serialize, Deserialize)] +//! struct MyStruct { +//! #[serde(with = "crate::core::number_serializers")] +//! sample_rate: f64, +//! } +//! ``` + +use serde::{self, Deserialize, Deserializer, Serialize, Serializer}; + +/// Serialize an f64, omitting the decimal point for whole numbers. +/// e.g., 24000.0 → 24000, 3.14 → 3.14 +pub fn serialize(value: &f64, serializer: S) -> Result +where + S: Serializer, +{ + if value.fract() == 0.0 + && value.is_finite() + && *value >= (i64::MIN as f64) + && *value <= (i64::MAX as f64) + { + // Serialize as integer to avoid trailing .0 + (*value as i64).serialize(serializer) + } else { + value.serialize(serializer) + } +} + +/// Deserialize an f64 (accepts both integer and float JSON values) +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + f64::deserialize(deserializer) +} + +/// Module for optional f64 fields +pub mod option { + use super::*; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(v) => { + if v.fract() == 0.0 + && v.is_finite() + && *v >= (i64::MIN as f64) + && *v <= (i64::MAX as f64) + { + serializer.serialize_some(&(*v as i64)) + } else { + serializer.serialize_some(v) + } + } + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStruct { + #[serde(with = "super")] + value: f64, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStructOptional { + #[serde(default)] + #[serde(with = "super::option")] + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + } + + #[test] + fn test_whole_number_no_decimal() { + let test = TestStruct { value: 24000.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":24000}"#); + } + + #[test] + fn test_fractional_keeps_decimal() { + let test = TestStruct { value: 3.14 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":3.14}"#); + } + + #[test] + fn test_zero() { + let test = TestStruct { value: 0.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":0}"#); + } + + #[test] + fn test_negative_whole() { + let test = TestStruct { value: -100.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":-100}"#); + } + + #[test] + fn test_deserialize_from_integer() { + let json = r#"{"value":24000}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, 24000.0); + } + + #[test] + fn test_deserialize_from_float() { + let json = r#"{"value":3.14}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, 3.14); + } + + #[test] + fn test_roundtrip() { + let original = TestStruct { value: 44100.0 }; + let json = serde_json::to_string(&original).unwrap(); + let decoded: TestStruct = serde_json::from_str(&json).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn test_optional_some_whole() { + let test = TestStructOptional { + value: Some(16000.0), + }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":16000}"#); + } + + #[test] + fn test_optional_none() { + let test = TestStructOptional { value: None }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{}"#); + } + + #[test] + fn test_optional_deserialize_missing() { + let json = r#"{}"#; + let test: TestStructOptional = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, None); + } + + #[test] + fn test_large_whole_number_outside_i64_range() { + let test = TestStruct { value: 1e20 }; + let json = serde_json::to_string(&test).unwrap(); + // Should fall back to f64 serialization, not saturate to i64::MAX + assert_eq!(json, r#"{"value":1e+20}"#); + } +} diff --git a/seed/rust-sdk/allof/dynamic-snippets/example10.rs b/seed/rust-sdk/allof/dynamic-snippets/example10.rs new file mode 100644 index 000000000000..e260305e6f8d --- /dev/null +++ b/seed/rust-sdk/allof/dynamic-snippets/example10.rs @@ -0,0 +1,25 @@ +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + base_url: "https://api.fern.com".to_string(), + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_plant( + &PlantPost { + species: "species".to_string(), + family: "family".to_string(), + genus: "genus".to_string(), + sun_exposure: PlantPostSunExposure::Full, + common_name: None, + watering_frequency: None, + planted_at: None, + soil_type: None, + }, + None, + ) + .await; +} diff --git a/seed/rust-sdk/allof/dynamic-snippets/example11.rs b/seed/rust-sdk/allof/dynamic-snippets/example11.rs new file mode 100644 index 000000000000..73de44b23da5 --- /dev/null +++ b/seed/rust-sdk/allof/dynamic-snippets/example11.rs @@ -0,0 +1,25 @@ +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + base_url: "https://api.fern.com".to_string(), + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_plant( + &PlantPost { + common_name: Some("commonName".to_string()), + watering_frequency: Some(PlantBaseWateringFrequency::Daily), + species: "species".to_string(), + family: "family".to_string(), + genus: "genus".to_string(), + sun_exposure: PlantPostSunExposure::Full, + planted_at: Some(NaiveDate::parse_from_str("2023-01-15", "%Y-%m-%d").unwrap()), + soil_type: Some("soilType".to_string()), + }, + None, + ) + .await; +} diff --git a/seed/rust-sdk/allof/dynamic-snippets/example12.rs b/seed/rust-sdk/allof/dynamic-snippets/example12.rs new file mode 100644 index 000000000000..24127592970f --- /dev/null +++ b/seed/rust-sdk/allof/dynamic-snippets/example12.rs @@ -0,0 +1,22 @@ +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + base_url: "https://api.fern.com".to_string(), + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_tree( + &TreeRecord { + tree_base_fields: TreeBase { + id: "id".to_string(), + ..Default::default() + }, + ..Default::default() + }, + None, + ) + .await; +} diff --git a/seed/rust-sdk/allof/dynamic-snippets/example13.rs b/seed/rust-sdk/allof/dynamic-snippets/example13.rs new file mode 100644 index 000000000000..24feee4100a8 --- /dev/null +++ b/seed/rust-sdk/allof/dynamic-snippets/example13.rs @@ -0,0 +1,27 @@ +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + base_url: "https://api.fern.com".to_string(), + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_tree( + &TreeRecord { + tree_base_fields: TreeBase { + tree_species: Some("treeSpecies".to_string()), + height_in_feet: Some(1.1), + id: "id".to_string(), + tree_name: Some("treeName".to_string()), + tree_description: Some("treeDescription".to_string()), + ..Default::default() + }, + planted_date: Some(NaiveDate::parse_from_str("2023-01-15", "%Y-%m-%d").unwrap()), + ..Default::default() + }, + None, + ) + .await; +} diff --git a/seed/rust-sdk/allof/reference.md b/seed/rust-sdk/allof/reference.md index e0e9deb87648..b5424f30ed2e 100644 --- a/seed/rust-sdk/allof/reference.md +++ b/seed/rust-sdk/allof/reference.md @@ -222,3 +222,155 @@ async fn main() { +
client.create_plant(request: PlantPost) -> Result<PlantStrict, ApiError> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```rust +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_plant( + &PlantPost { + species: "species".to_string(), + family: "family".to_string(), + genus: "genus".to_string(), + sun_exposure: PlantPostSunExposure::Full, + common_name: None, + watering_frequency: None, + planted_at: None, + soil_type: None, + }, + None, + ) + .await; +} +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**sun_exposure:** `PlantPostSunExposure` — Required sun exposure level. + +
+
+ +
+
+ +**planted_at:** `Option` — Date the plant was planted. + +
+
+ +
+
+ +**soil_type:** `Option` — Preferred soil type. + +
+
+
+
+ + +
+
+
+ +
client.create_tree(request: TreeRecord) -> Result<TreeRecord, ApiError> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```rust +use seed_api::prelude::*; + +#[tokio::main] +async fn main() { + let config = ClientConfig { + ..Default::default() + }; + let client = ApiClient::new(config).expect("Failed to build client"); + client + .create_tree( + &TreeRecord { + tree_base_fields: TreeBase { + id: "id".to_string(), + ..Default::default() + }, + ..Default::default() + }, + None, + ) + .await; +} +``` +
+
+
+
+ + +
+
+
+ diff --git a/seed/rust-sdk/allof/src/api/resources/mod.rs b/seed/rust-sdk/allof/src/api/resources/mod.rs index 543ca7436f76..d732a700d853 100644 --- a/seed/rust-sdk/allof/src/api/resources/mod.rs +++ b/seed/rust-sdk/allof/src/api/resources/mod.rs @@ -79,4 +79,54 @@ impl ApiClient { .execute_request(Method::GET, "organizations", None, None, options) .await } + + /// Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + pub async fn create_plant( + &self, + request: &PlantPost, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "plants", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + pub async fn create_tree( + &self, + request: &TreeRecord, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "trees", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } } diff --git a/seed/rust-sdk/allof/src/api/types/mod.rs b/seed/rust-sdk/allof/src/api/types/mod.rs index a298b04bd9da..b6eff5a4edbf 100644 --- a/seed/rust-sdk/allof/src/api/types/mod.rs +++ b/seed/rust-sdk/allof/src/api/types/mod.rs @@ -10,6 +10,11 @@ pub mod identifiable; pub mod organization; pub mod paginated_result; pub mod paging_cursors; +pub mod plant_base; +pub mod plant_base_watering_frequency; +pub mod plant_post; +pub mod plant_post_sun_exposure; +pub mod plant_strict; pub mod rule_create_request; pub mod rule_create_request_execution_context; pub mod rule_execution_context; @@ -18,6 +23,10 @@ pub mod rule_response_status; pub mod rule_type; pub mod rule_type_search_response; pub mod search_rule_types_query_request; +pub mod tree_base; +pub mod tree_describable; +pub mod tree_identifiable; +pub mod tree_record; pub mod user; pub mod user_search_response; @@ -33,6 +42,11 @@ pub use identifiable::Identifiable; pub use organization::Organization; pub use paginated_result::PaginatedResult; pub use paging_cursors::PagingCursors; +pub use plant_base::PlantBase; +pub use plant_base_watering_frequency::PlantBaseWateringFrequency; +pub use plant_post::PlantPost; +pub use plant_post_sun_exposure::PlantPostSunExposure; +pub use plant_strict::PlantStrict; pub use rule_create_request::RuleCreateRequest; pub use rule_create_request_execution_context::RuleCreateRequestExecutionContext; pub use rule_execution_context::RuleExecutionContext; @@ -41,5 +55,9 @@ pub use rule_response_status::RuleResponseStatus; pub use rule_type::RuleType; pub use rule_type_search_response::RuleTypeSearchResponse; pub use search_rule_types_query_request::SearchRuleTypesQueryRequest; +pub use tree_base::TreeBase; +pub use tree_describable::TreeDescribable; +pub use tree_identifiable::TreeIdentifiable; +pub use tree_record::TreeRecord; pub use user::User; pub use user_search_response::UserSearchResponse; diff --git a/seed/rust-sdk/allof/src/api/types/plant_base.rs b/seed/rust-sdk/allof/src/api/types/plant_base.rs new file mode 100644 index 000000000000..ffd1959ad284 --- /dev/null +++ b/seed/rust-sdk/allof/src/api/types/plant_base.rs @@ -0,0 +1,58 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PlantBase { + #[serde(flatten)] + pub plant_strict_fields: PlantStrict, + /// The common name of the plant. + #[serde(rename = "commonName")] + #[serde(skip_serializing_if = "Option::is_none")] + pub common_name: Option, + #[serde(rename = "wateringFrequency")] + #[serde(skip_serializing_if = "Option::is_none")] + pub watering_frequency: Option, +} + +impl PlantBase { + pub fn builder() -> PlantBaseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PlantBaseBuilder { + plant_strict_fields: Option, + common_name: Option, + watering_frequency: Option, +} + +impl PlantBaseBuilder { + pub fn plant_strict_fields(mut self, value: PlantStrict) -> Self { + self.plant_strict_fields = Some(value); + self + } + + pub fn common_name(mut self, value: impl Into) -> Self { + self.common_name = Some(value.into()); + self + } + + pub fn watering_frequency(mut self, value: PlantBaseWateringFrequency) -> Self { + self.watering_frequency = Some(value); + self + } + + /// Consumes the builder and constructs a [`PlantBase`]. + /// This method will fail if any of the following fields are not set: + /// - [`plant_strict_fields`](PlantBaseBuilder::plant_strict_fields) + pub fn build(self) -> Result { + Ok(PlantBase { + plant_strict_fields: self + .plant_strict_fields + .ok_or_else(|| BuildError::missing_field("plant_strict_fields"))?, + common_name: self.common_name, + watering_frequency: self.watering_frequency, + }) + } +} diff --git a/seed/rust-sdk/allof/src/api/types/plant_base_watering_frequency.rs b/seed/rust-sdk/allof/src/api/types/plant_base_watering_frequency.rs new file mode 100644 index 000000000000..0dfe6e7273d8 --- /dev/null +++ b/seed/rust-sdk/allof/src/api/types/plant_base_watering_frequency.rs @@ -0,0 +1,50 @@ +pub use crate::prelude::*; + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PlantBaseWateringFrequency { + Daily, + Weekly, + Biweekly, + Monthly, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for PlantBaseWateringFrequency { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Daily => serializer.serialize_str("daily"), + Self::Weekly => serializer.serialize_str("weekly"), + Self::Biweekly => serializer.serialize_str("biweekly"), + Self::Monthly => serializer.serialize_str("monthly"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for PlantBaseWateringFrequency { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "daily" => Ok(Self::Daily), + "weekly" => Ok(Self::Weekly), + "biweekly" => Ok(Self::Biweekly), + "monthly" => Ok(Self::Monthly), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for PlantBaseWateringFrequency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Daily => write!(f, "daily"), + Self::Weekly => write!(f, "weekly"), + Self::Biweekly => write!(f, "biweekly"), + Self::Monthly => write!(f, "monthly"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/seed/rust-sdk/allof/src/api/types/plant_post.rs b/seed/rust-sdk/allof/src/api/types/plant_post.rs new file mode 100644 index 000000000000..826a2be98006 --- /dev/null +++ b/seed/rust-sdk/allof/src/api/types/plant_post.rs @@ -0,0 +1,120 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct PlantPost { + /// Required sun exposure level. + #[serde(rename = "sunExposure")] + pub sun_exposure: PlantPostSunExposure, + /// Date the plant was planted. + #[serde(rename = "plantedAt")] + #[serde(skip_serializing_if = "Option::is_none")] + pub planted_at: Option, + /// Preferred soil type. + #[serde(rename = "soilType")] + #[serde(skip_serializing_if = "Option::is_none")] + pub soil_type: Option, + /// The common name of the plant. + #[serde(rename = "commonName")] + #[serde(skip_serializing_if = "Option::is_none")] + pub common_name: Option, + #[serde(rename = "wateringFrequency")] + #[serde(skip_serializing_if = "Option::is_none")] + pub watering_frequency: Option, + /// The botanical species name. + #[serde(default)] + pub species: String, + /// The botanical family. + #[serde(default)] + pub family: String, + /// The botanical genus. + #[serde(default)] + pub genus: String, +} + +impl PlantPost { + pub fn builder() -> PlantPostBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PlantPostBuilder { + sun_exposure: Option, + planted_at: Option, + soil_type: Option, + common_name: Option, + watering_frequency: Option, + species: Option, + family: Option, + genus: Option, +} + +impl PlantPostBuilder { + pub fn sun_exposure(mut self, value: PlantPostSunExposure) -> Self { + self.sun_exposure = Some(value); + self + } + + pub fn planted_at(mut self, value: NaiveDate) -> Self { + self.planted_at = Some(value); + self + } + + pub fn soil_type(mut self, value: impl Into) -> Self { + self.soil_type = Some(value.into()); + self + } + + pub fn common_name(mut self, value: impl Into) -> Self { + self.common_name = Some(value.into()); + self + } + + pub fn watering_frequency(mut self, value: PlantBaseWateringFrequency) -> Self { + self.watering_frequency = Some(value); + self + } + + pub fn species(mut self, value: impl Into) -> Self { + self.species = Some(value.into()); + self + } + + pub fn family(mut self, value: impl Into) -> Self { + self.family = Some(value.into()); + self + } + + pub fn genus(mut self, value: impl Into) -> Self { + self.genus = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`PlantPost`]. + /// This method will fail if any of the following fields are not set: + /// - [`sun_exposure`](PlantPostBuilder::sun_exposure) + /// - [`species`](PlantPostBuilder::species) + /// - [`family`](PlantPostBuilder::family) + /// - [`genus`](PlantPostBuilder::genus) + pub fn build(self) -> Result { + Ok(PlantPost { + sun_exposure: self + .sun_exposure + .ok_or_else(|| BuildError::missing_field("sun_exposure"))?, + planted_at: self.planted_at, + soil_type: self.soil_type, + common_name: self.common_name, + watering_frequency: self.watering_frequency, + species: self + .species + .ok_or_else(|| BuildError::missing_field("species"))?, + family: self + .family + .ok_or_else(|| BuildError::missing_field("family"))?, + genus: self + .genus + .ok_or_else(|| BuildError::missing_field("genus"))?, + }) + } +} diff --git a/seed/rust-sdk/allof/src/api/types/plant_post_sun_exposure.rs b/seed/rust-sdk/allof/src/api/types/plant_post_sun_exposure.rs new file mode 100644 index 000000000000..657b2b8d4e36 --- /dev/null +++ b/seed/rust-sdk/allof/src/api/types/plant_post_sun_exposure.rs @@ -0,0 +1,47 @@ +pub use crate::prelude::*; + +/// Required sun exposure level. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PlantPostSunExposure { + Full, + Partial, + Shade, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for PlantPostSunExposure { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Full => serializer.serialize_str("full"), + Self::Partial => serializer.serialize_str("partial"), + Self::Shade => serializer.serialize_str("shade"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for PlantPostSunExposure { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "full" => Ok(Self::Full), + "partial" => Ok(Self::Partial), + "shade" => Ok(Self::Shade), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for PlantPostSunExposure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Full => write!(f, "full"), + Self::Partial => write!(f, "partial"), + Self::Shade => write!(f, "shade"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/seed/rust-sdk/allof/src/api/types/plant_strict.rs b/seed/rust-sdk/allof/src/api/types/plant_strict.rs new file mode 100644 index 000000000000..8bffd2121d3d --- /dev/null +++ b/seed/rust-sdk/allof/src/api/types/plant_strict.rs @@ -0,0 +1,64 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PlantStrict { + /// The botanical species name. + #[serde(default)] + pub species: String, + /// The botanical family. + #[serde(default)] + pub family: String, + /// The botanical genus. + #[serde(default)] + pub genus: String, +} + +impl PlantStrict { + pub fn builder() -> PlantStrictBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PlantStrictBuilder { + species: Option, + family: Option, + genus: Option, +} + +impl PlantStrictBuilder { + pub fn species(mut self, value: impl Into) -> Self { + self.species = Some(value.into()); + self + } + + pub fn family(mut self, value: impl Into) -> Self { + self.family = Some(value.into()); + self + } + + pub fn genus(mut self, value: impl Into) -> Self { + self.genus = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`PlantStrict`]. + /// This method will fail if any of the following fields are not set: + /// - [`species`](PlantStrictBuilder::species) + /// - [`family`](PlantStrictBuilder::family) + /// - [`genus`](PlantStrictBuilder::genus) + pub fn build(self) -> Result { + Ok(PlantStrict { + species: self + .species + .ok_or_else(|| BuildError::missing_field("species"))?, + family: self + .family + .ok_or_else(|| BuildError::missing_field("family"))?, + genus: self + .genus + .ok_or_else(|| BuildError::missing_field("genus"))?, + }) + } +} diff --git a/seed/rust-sdk/allof/src/api/types/tree_base.rs b/seed/rust-sdk/allof/src/api/types/tree_base.rs new file mode 100644 index 000000000000..23f219a61c02 --- /dev/null +++ b/seed/rust-sdk/allof/src/api/types/tree_base.rs @@ -0,0 +1,73 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct TreeBase { + #[serde(flatten)] + pub tree_identifiable_fields: TreeIdentifiable, + #[serde(flatten)] + pub tree_describable_fields: TreeDescribable, + /// The species of tree. + #[serde(rename = "treeSpecies")] + #[serde(skip_serializing_if = "Option::is_none")] + pub tree_species: Option, + /// Height of the tree in feet. + #[serde(rename = "heightInFeet")] + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[serde(with = "crate::core::number_serializers::option")] + pub height_in_feet: Option, +} + +impl TreeBase { + pub fn builder() -> TreeBaseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct TreeBaseBuilder { + tree_identifiable_fields: Option, + tree_describable_fields: Option, + tree_species: Option, + height_in_feet: Option, +} + +impl TreeBaseBuilder { + pub fn tree_identifiable_fields(mut self, value: TreeIdentifiable) -> Self { + self.tree_identifiable_fields = Some(value); + self + } + + pub fn tree_describable_fields(mut self, value: TreeDescribable) -> Self { + self.tree_describable_fields = Some(value); + self + } + + pub fn tree_species(mut self, value: impl Into) -> Self { + self.tree_species = Some(value.into()); + self + } + + pub fn height_in_feet(mut self, value: f64) -> Self { + self.height_in_feet = Some(value); + self + } + + /// Consumes the builder and constructs a [`TreeBase`]. + /// This method will fail if any of the following fields are not set: + /// - [`tree_identifiable_fields`](TreeBaseBuilder::tree_identifiable_fields) + /// - [`tree_describable_fields`](TreeBaseBuilder::tree_describable_fields) + pub fn build(self) -> Result { + Ok(TreeBase { + tree_identifiable_fields: self + .tree_identifiable_fields + .ok_or_else(|| BuildError::missing_field("tree_identifiable_fields"))?, + tree_describable_fields: self + .tree_describable_fields + .ok_or_else(|| BuildError::missing_field("tree_describable_fields"))?, + tree_species: self.tree_species, + height_in_feet: self.height_in_feet, + }) + } +} diff --git a/seed/rust-sdk/allof/src/api/types/tree_describable.rs b/seed/rust-sdk/allof/src/api/types/tree_describable.rs new file mode 100644 index 000000000000..56c625e44c45 --- /dev/null +++ b/seed/rust-sdk/allof/src/api/types/tree_describable.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct TreeDescribable { + /// Display name of the tree. + #[serde(rename = "treeName")] + #[serde(skip_serializing_if = "Option::is_none")] + pub tree_name: Option, + /// A description of the tree. + #[serde(rename = "treeDescription")] + #[serde(skip_serializing_if = "Option::is_none")] + pub tree_description: Option, +} + +impl TreeDescribable { + pub fn builder() -> TreeDescribableBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct TreeDescribableBuilder { + tree_name: Option, + tree_description: Option, +} + +impl TreeDescribableBuilder { + pub fn tree_name(mut self, value: impl Into) -> Self { + self.tree_name = Some(value.into()); + self + } + + pub fn tree_description(mut self, value: impl Into) -> Self { + self.tree_description = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`TreeDescribable`]. + pub fn build(self) -> Result { + Ok(TreeDescribable { + tree_name: self.tree_name, + tree_description: self.tree_description, + }) + } +} diff --git a/seed/rust-sdk/allof/src/api/types/tree_identifiable.rs b/seed/rust-sdk/allof/src/api/types/tree_identifiable.rs new file mode 100644 index 000000000000..c18da6e8f86c --- /dev/null +++ b/seed/rust-sdk/allof/src/api/types/tree_identifiable.rs @@ -0,0 +1,36 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct TreeIdentifiable { + /// Unique tree identifier. + #[serde(default)] + pub id: String, +} + +impl TreeIdentifiable { + pub fn builder() -> TreeIdentifiableBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct TreeIdentifiableBuilder { + id: Option, +} + +impl TreeIdentifiableBuilder { + pub fn id(mut self, value: impl Into) -> Self { + self.id = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`TreeIdentifiable`]. + /// This method will fail if any of the following fields are not set: + /// - [`id`](TreeIdentifiableBuilder::id) + pub fn build(self) -> Result { + Ok(TreeIdentifiable { + id: self.id.ok_or_else(|| BuildError::missing_field("id"))?, + }) + } +} diff --git a/seed/rust-sdk/allof/src/api/types/tree_record.rs b/seed/rust-sdk/allof/src/api/types/tree_record.rs new file mode 100644 index 000000000000..a1405a9b8ba7 --- /dev/null +++ b/seed/rust-sdk/allof/src/api/types/tree_record.rs @@ -0,0 +1,48 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct TreeRecord { + #[serde(flatten)] + pub tree_base_fields: TreeBase, + /// Date the tree was planted. + #[serde(rename = "plantedDate")] + #[serde(skip_serializing_if = "Option::is_none")] + pub planted_date: Option, +} + +impl TreeRecord { + pub fn builder() -> TreeRecordBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct TreeRecordBuilder { + tree_base_fields: Option, + planted_date: Option, +} + +impl TreeRecordBuilder { + pub fn tree_base_fields(mut self, value: TreeBase) -> Self { + self.tree_base_fields = Some(value); + self + } + + pub fn planted_date(mut self, value: NaiveDate) -> Self { + self.planted_date = Some(value); + self + } + + /// Consumes the builder and constructs a [`TreeRecord`]. + /// This method will fail if any of the following fields are not set: + /// - [`tree_base_fields`](TreeRecordBuilder::tree_base_fields) + pub fn build(self) -> Result { + Ok(TreeRecord { + tree_base_fields: self + .tree_base_fields + .ok_or_else(|| BuildError::missing_field("tree_base_fields"))?, + planted_date: self.planted_date, + }) + } +} diff --git a/seed/rust-sdk/allof/src/core/mod.rs b/seed/rust-sdk/allof/src/core/mod.rs index 3050ad197607..3681fce54715 100644 --- a/seed/rust-sdk/allof/src/core/mod.rs +++ b/seed/rust-sdk/allof/src/core/mod.rs @@ -2,6 +2,7 @@ pub mod flexible_datetime; mod http_client; +pub mod number_serializers; mod oauth_token_provider; pub mod pagination; mod query_parameter_builder; diff --git a/seed/rust-sdk/allof/src/core/number_serializers.rs b/seed/rust-sdk/allof/src/core/number_serializers.rs new file mode 100644 index 000000000000..0e16157bfe9a --- /dev/null +++ b/seed/rust-sdk/allof/src/core/number_serializers.rs @@ -0,0 +1,177 @@ +//! Number serialization helpers +//! +//! This module provides serde helpers for serializing f64 values +//! that strips trailing `.0` from whole numbers (e.g., 24000.0 → 24000). +//! Some APIs reject the decimal representation for integer-valued numbers. +//! +//! Usage: +//! ```rust +//! use serde::{Deserialize, Serialize}; +//! +//! #[derive(Serialize, Deserialize)] +//! struct MyStruct { +//! #[serde(with = "crate::core::number_serializers")] +//! sample_rate: f64, +//! } +//! ``` + +use serde::{self, Deserialize, Deserializer, Serialize, Serializer}; + +/// Serialize an f64, omitting the decimal point for whole numbers. +/// e.g., 24000.0 → 24000, 3.14 → 3.14 +pub fn serialize(value: &f64, serializer: S) -> Result +where + S: Serializer, +{ + if value.fract() == 0.0 + && value.is_finite() + && *value >= (i64::MIN as f64) + && *value <= (i64::MAX as f64) + { + // Serialize as integer to avoid trailing .0 + (*value as i64).serialize(serializer) + } else { + value.serialize(serializer) + } +} + +/// Deserialize an f64 (accepts both integer and float JSON values) +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + f64::deserialize(deserializer) +} + +/// Module for optional f64 fields +pub mod option { + use super::*; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(v) => { + if v.fract() == 0.0 + && v.is_finite() + && *v >= (i64::MIN as f64) + && *v <= (i64::MAX as f64) + { + serializer.serialize_some(&(*v as i64)) + } else { + serializer.serialize_some(v) + } + } + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStruct { + #[serde(with = "super")] + value: f64, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStructOptional { + #[serde(default)] + #[serde(with = "super::option")] + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + } + + #[test] + fn test_whole_number_no_decimal() { + let test = TestStruct { value: 24000.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":24000}"#); + } + + #[test] + fn test_fractional_keeps_decimal() { + let test = TestStruct { value: 3.14 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":3.14}"#); + } + + #[test] + fn test_zero() { + let test = TestStruct { value: 0.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":0}"#); + } + + #[test] + fn test_negative_whole() { + let test = TestStruct { value: -100.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":-100}"#); + } + + #[test] + fn test_deserialize_from_integer() { + let json = r#"{"value":24000}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, 24000.0); + } + + #[test] + fn test_deserialize_from_float() { + let json = r#"{"value":3.14}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, 3.14); + } + + #[test] + fn test_roundtrip() { + let original = TestStruct { value: 44100.0 }; + let json = serde_json::to_string(&original).unwrap(); + let decoded: TestStruct = serde_json::from_str(&json).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn test_optional_some_whole() { + let test = TestStructOptional { + value: Some(16000.0), + }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":16000}"#); + } + + #[test] + fn test_optional_none() { + let test = TestStructOptional { value: None }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{}"#); + } + + #[test] + fn test_optional_deserialize_missing() { + let json = r#"{}"#; + let test: TestStructOptional = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, None); + } + + #[test] + fn test_large_whole_number_outside_i64_range() { + let test = TestStruct { value: 1e20 }; + let json = serde_json::to_string(&test).unwrap(); + // Should fall back to f64 serialization, not saturate to i64::MAX + assert_eq!(json, r#"{"value":1e+20}"#); + } +} diff --git a/seed/swift-sdk/allof-inline/Snippets/Example10.swift b/seed/swift-sdk/allof-inline/Snippets/Example10.swift new file mode 100644 index 000000000000..d8931b0d69a2 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Snippets/Example10.swift @@ -0,0 +1,17 @@ +import Foundation +import Api + +private func main() async throws { + let client = ApiClient(baseURL: "https://api.fern.com") + + _ = try await client.createPlant(request: .init( + species: "species", + family: "family", + genus: "genus", + commonName: "commonName", + wateringFrequency: .daily, + sunExposure: .full + )) +} + +try await main() diff --git a/seed/swift-sdk/allof-inline/Snippets/Example11.swift b/seed/swift-sdk/allof-inline/Snippets/Example11.swift new file mode 100644 index 000000000000..93cfabcf0b54 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Snippets/Example11.swift @@ -0,0 +1,19 @@ +import Foundation +import Api + +private func main() async throws { + let client = ApiClient(baseURL: "https://api.fern.com") + + _ = try await client.createPlant(request: .init( + species: "species", + family: "family", + genus: "genus", + commonName: "commonName", + wateringFrequency: .daily, + sunExposure: .full, + plantedAt: CalendarDate("2023-01-15")!, + soilType: "soilType" + )) +} + +try await main() diff --git a/seed/swift-sdk/allof-inline/Snippets/Example12.swift b/seed/swift-sdk/allof-inline/Snippets/Example12.swift new file mode 100644 index 000000000000..f8df4516c3c8 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Snippets/Example12.swift @@ -0,0 +1,14 @@ +import Foundation +import Api + +private func main() async throws { + let client = ApiClient(baseURL: "https://api.fern.com") + + _ = try await client.createTree(request: TreeRecord( + id: "id", + treeName: "treeName", + treeSpecies: "treeSpecies" + )) +} + +try await main() diff --git a/seed/swift-sdk/allof-inline/Snippets/Example13.swift b/seed/swift-sdk/allof-inline/Snippets/Example13.swift new file mode 100644 index 000000000000..8e5577b9c907 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Snippets/Example13.swift @@ -0,0 +1,17 @@ +import Foundation +import Api + +private func main() async throws { + let client = ApiClient(baseURL: "https://api.fern.com") + + _ = try await client.createTree(request: TreeRecord( + id: "id", + treeName: "treeName", + treeDescription: "treeDescription", + treeSpecies: "treeSpecies", + heightInFeet: 1.1, + plantedDate: CalendarDate("2023-01-15")! + )) +} + +try await main() diff --git a/seed/swift-sdk/allof-inline/Sources/ApiClient.swift b/seed/swift-sdk/allof-inline/Sources/ApiClient.swift index 3bb0c30a67c5..4c4861c3b599 100644 --- a/seed/swift-sdk/allof-inline/Sources/ApiClient.swift +++ b/seed/swift-sdk/allof-inline/Sources/ApiClient.swift @@ -101,4 +101,30 @@ public final class ApiClient: Sendable { responseType: Organization.self ) } + + /// Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + /// + /// - Parameter requestOptions: Additional options for configuring the request, such as custom headers or timeout settings. + public func createPlant(request: Requests.PlantPost, requestOptions: RequestOptions? = nil) async throws -> PlantStrict { + return try await httpClient.performRequest( + method: .post, + path: "/plants", + body: request, + requestOptions: requestOptions, + responseType: PlantStrict.self + ) + } + + /// Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + /// + /// - Parameter requestOptions: Additional options for configuring the request, such as custom headers or timeout settings. + public func createTree(request: TreeRecord, requestOptions: RequestOptions? = nil) async throws -> TreeRecord { + return try await httpClient.performRequest( + method: .post, + path: "/trees", + body: request, + requestOptions: requestOptions, + responseType: TreeRecord.self + ) + } } \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/Sources/Requests/Requests+PlantPost.swift b/seed/swift-sdk/allof-inline/Sources/Requests/Requests+PlantPost.swift new file mode 100644 index 000000000000..1df6fe5a20d1 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Sources/Requests/Requests+PlantPost.swift @@ -0,0 +1,83 @@ +import Foundation + +extension Requests { + public struct PlantPost: Codable, Hashable, Sendable { + /// The botanical species name. + public let species: String + /// The botanical family. + public let family: String + /// The botanical genus. + public let genus: String + /// The common name of the plant. + public let commonName: String + public let wateringFrequency: PlantPostWateringFrequency + /// Required sun exposure level. + public let sunExposure: PlantPostSunExposure + /// Date the plant was planted. + public let plantedAt: CalendarDate? + /// Preferred soil type. + public let soilType: String? + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + species: String, + family: String, + genus: String, + commonName: String, + wateringFrequency: PlantPostWateringFrequency, + sunExposure: PlantPostSunExposure, + plantedAt: CalendarDate? = nil, + soilType: String? = nil, + additionalProperties: [String: JSONValue] = .init() + ) { + self.species = species + self.family = family + self.genus = genus + self.commonName = commonName + self.wateringFrequency = wateringFrequency + self.sunExposure = sunExposure + self.plantedAt = plantedAt + self.soilType = soilType + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.species = try container.decode(String.self, forKey: .species) + self.family = try container.decode(String.self, forKey: .family) + self.genus = try container.decode(String.self, forKey: .genus) + self.commonName = try container.decode(String.self, forKey: .commonName) + self.wateringFrequency = try container.decode(PlantPostWateringFrequency.self, forKey: .wateringFrequency) + self.sunExposure = try container.decode(PlantPostSunExposure.self, forKey: .sunExposure) + self.plantedAt = try container.decodeIfPresent(CalendarDate.self, forKey: .plantedAt) + self.soilType = try container.decodeIfPresent(String.self, forKey: .soilType) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encode(self.species, forKey: .species) + try container.encode(self.family, forKey: .family) + try container.encode(self.genus, forKey: .genus) + try container.encode(self.commonName, forKey: .commonName) + try container.encode(self.wateringFrequency, forKey: .wateringFrequency) + try container.encode(self.sunExposure, forKey: .sunExposure) + try container.encodeIfPresent(self.plantedAt, forKey: .plantedAt) + try container.encodeIfPresent(self.soilType, forKey: .soilType) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case species + case family + case genus + case commonName + case wateringFrequency + case sunExposure + case plantedAt + case soilType + } + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/Sources/Schemas/PlantBase.swift b/seed/swift-sdk/allof-inline/Sources/Schemas/PlantBase.swift new file mode 100644 index 000000000000..83e47413fffb --- /dev/null +++ b/seed/swift-sdk/allof-inline/Sources/Schemas/PlantBase.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct PlantBase: Codable, Hashable, Sendable { + /// The botanical species name. + public let species: String + /// The botanical family. + public let family: String + /// The botanical genus. + public let genus: String + /// The common name of the plant. + public let commonName: String? + public let wateringFrequency: PlantBaseWateringFrequency? + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + species: String, + family: String, + genus: String, + commonName: String? = nil, + wateringFrequency: PlantBaseWateringFrequency? = nil, + additionalProperties: [String: JSONValue] = .init() + ) { + self.species = species + self.family = family + self.genus = genus + self.commonName = commonName + self.wateringFrequency = wateringFrequency + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.species = try container.decode(String.self, forKey: .species) + self.family = try container.decode(String.self, forKey: .family) + self.genus = try container.decode(String.self, forKey: .genus) + self.commonName = try container.decodeIfPresent(String.self, forKey: .commonName) + self.wateringFrequency = try container.decodeIfPresent(PlantBaseWateringFrequency.self, forKey: .wateringFrequency) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encode(self.species, forKey: .species) + try container.encode(self.family, forKey: .family) + try container.encode(self.genus, forKey: .genus) + try container.encodeIfPresent(self.commonName, forKey: .commonName) + try container.encodeIfPresent(self.wateringFrequency, forKey: .wateringFrequency) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case species + case family + case genus + case commonName + case wateringFrequency + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/Sources/Schemas/PlantBaseWateringFrequency.swift b/seed/swift-sdk/allof-inline/Sources/Schemas/PlantBaseWateringFrequency.swift new file mode 100644 index 000000000000..0fdbe24ce526 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Sources/Schemas/PlantBaseWateringFrequency.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum PlantBaseWateringFrequency: String, Codable, Hashable, CaseIterable, Sendable { + case daily + case weekly + case biweekly + case monthly +} \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/Sources/Schemas/PlantPostSunExposure.swift b/seed/swift-sdk/allof-inline/Sources/Schemas/PlantPostSunExposure.swift new file mode 100644 index 000000000000..dceb046b43aa --- /dev/null +++ b/seed/swift-sdk/allof-inline/Sources/Schemas/PlantPostSunExposure.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Required sun exposure level. +public enum PlantPostSunExposure: String, Codable, Hashable, CaseIterable, Sendable { + case full + case partial + case shade +} \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/Sources/Schemas/PlantPostWateringFrequency.swift b/seed/swift-sdk/allof-inline/Sources/Schemas/PlantPostWateringFrequency.swift new file mode 100644 index 000000000000..0403baa1e829 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Sources/Schemas/PlantPostWateringFrequency.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum PlantPostWateringFrequency: String, Codable, Hashable, CaseIterable, Sendable { + case daily + case weekly + case biweekly + case monthly +} \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/Sources/Schemas/PlantStrict.swift b/seed/swift-sdk/allof-inline/Sources/Schemas/PlantStrict.swift new file mode 100644 index 000000000000..3bda95c55a56 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Sources/Schemas/PlantStrict.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct PlantStrict: Codable, Hashable, Sendable { + /// The botanical species name. + public let species: String + /// The botanical family. + public let family: String + /// The botanical genus. + public let genus: String + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + species: String, + family: String, + genus: String, + additionalProperties: [String: JSONValue] = .init() + ) { + self.species = species + self.family = family + self.genus = genus + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.species = try container.decode(String.self, forKey: .species) + self.family = try container.decode(String.self, forKey: .family) + self.genus = try container.decode(String.self, forKey: .genus) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encode(self.species, forKey: .species) + try container.encode(self.family, forKey: .family) + try container.encode(self.genus, forKey: .genus) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case species + case family + case genus + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/Sources/Schemas/TreeBase.swift b/seed/swift-sdk/allof-inline/Sources/Schemas/TreeBase.swift new file mode 100644 index 000000000000..081bd4abd095 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Sources/Schemas/TreeBase.swift @@ -0,0 +1,61 @@ +import Foundation + +public struct TreeBase: Codable, Hashable, Sendable { + /// Unique tree identifier. + public let id: String + /// Display name of the tree. + public let treeName: String? + /// A description of the tree. + public let treeDescription: String? + /// The species of tree. + public let treeSpecies: String? + /// Height of the tree in feet. + public let heightInFeet: Double? + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + id: String, + treeName: String? = nil, + treeDescription: String? = nil, + treeSpecies: String? = nil, + heightInFeet: Double? = nil, + additionalProperties: [String: JSONValue] = .init() + ) { + self.id = id + self.treeName = treeName + self.treeDescription = treeDescription + self.treeSpecies = treeSpecies + self.heightInFeet = heightInFeet + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.treeName = try container.decodeIfPresent(String.self, forKey: .treeName) + self.treeDescription = try container.decodeIfPresent(String.self, forKey: .treeDescription) + self.treeSpecies = try container.decodeIfPresent(String.self, forKey: .treeSpecies) + self.heightInFeet = try container.decodeIfPresent(Double.self, forKey: .heightInFeet) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encode(self.id, forKey: .id) + try container.encodeIfPresent(self.treeName, forKey: .treeName) + try container.encodeIfPresent(self.treeDescription, forKey: .treeDescription) + try container.encodeIfPresent(self.treeSpecies, forKey: .treeSpecies) + try container.encodeIfPresent(self.heightInFeet, forKey: .heightInFeet) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case id + case treeName + case treeDescription + case treeSpecies + case heightInFeet + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/Sources/Schemas/TreeDescribable.swift b/seed/swift-sdk/allof-inline/Sources/Schemas/TreeDescribable.swift new file mode 100644 index 000000000000..df29e1c902a5 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Sources/Schemas/TreeDescribable.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct TreeDescribable: Codable, Hashable, Sendable { + /// Display name of the tree. + public let treeName: String? + /// A description of the tree. + public let treeDescription: String? + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + treeName: String? = nil, + treeDescription: String? = nil, + additionalProperties: [String: JSONValue] = .init() + ) { + self.treeName = treeName + self.treeDescription = treeDescription + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.treeName = try container.decodeIfPresent(String.self, forKey: .treeName) + self.treeDescription = try container.decodeIfPresent(String.self, forKey: .treeDescription) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encodeIfPresent(self.treeName, forKey: .treeName) + try container.encodeIfPresent(self.treeDescription, forKey: .treeDescription) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case treeName + case treeDescription + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/Sources/Schemas/TreeIdentifiable.swift b/seed/swift-sdk/allof-inline/Sources/Schemas/TreeIdentifiable.swift new file mode 100644 index 000000000000..412fdc8ae04a --- /dev/null +++ b/seed/swift-sdk/allof-inline/Sources/Schemas/TreeIdentifiable.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct TreeIdentifiable: Codable, Hashable, Sendable { + /// Unique tree identifier. + public let id: String + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + id: String, + additionalProperties: [String: JSONValue] = .init() + ) { + self.id = id + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encode(self.id, forKey: .id) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case id + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/Sources/Schemas/TreeRecord.swift b/seed/swift-sdk/allof-inline/Sources/Schemas/TreeRecord.swift new file mode 100644 index 000000000000..3f7f1597b545 --- /dev/null +++ b/seed/swift-sdk/allof-inline/Sources/Schemas/TreeRecord.swift @@ -0,0 +1,68 @@ +import Foundation + +public struct TreeRecord: Codable, Hashable, Sendable { + /// Unique tree identifier. + public let id: String + /// Display name of the tree. + public let treeName: String + /// A description of the tree. + public let treeDescription: String? + /// The species of tree. + public let treeSpecies: String + /// Height of the tree in feet. + public let heightInFeet: Double? + /// Date the tree was planted. + public let plantedDate: CalendarDate? + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + id: String, + treeName: String, + treeDescription: String? = nil, + treeSpecies: String, + heightInFeet: Double? = nil, + plantedDate: CalendarDate? = nil, + additionalProperties: [String: JSONValue] = .init() + ) { + self.id = id + self.treeName = treeName + self.treeDescription = treeDescription + self.treeSpecies = treeSpecies + self.heightInFeet = heightInFeet + self.plantedDate = plantedDate + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.treeName = try container.decode(String.self, forKey: .treeName) + self.treeDescription = try container.decodeIfPresent(String.self, forKey: .treeDescription) + self.treeSpecies = try container.decode(String.self, forKey: .treeSpecies) + self.heightInFeet = try container.decodeIfPresent(Double.self, forKey: .heightInFeet) + self.plantedDate = try container.decodeIfPresent(CalendarDate.self, forKey: .plantedDate) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encode(self.id, forKey: .id) + try container.encode(self.treeName, forKey: .treeName) + try container.encodeIfPresent(self.treeDescription, forKey: .treeDescription) + try container.encode(self.treeSpecies, forKey: .treeSpecies) + try container.encodeIfPresent(self.heightInFeet, forKey: .heightInFeet) + try container.encodeIfPresent(self.plantedDate, forKey: .plantedDate) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case id + case treeName + case treeDescription + case treeSpecies + case heightInFeet + case plantedDate + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof-inline/reference.md b/seed/swift-sdk/allof-inline/reference.md index 75ea9a025cca..52fc49b6ea40 100644 --- a/seed/swift-sdk/allof-inline/reference.md +++ b/seed/swift-sdk/allof-inline/reference.md @@ -263,3 +263,156 @@ try await main() +
client.createPlant(request: Requests.PlantPost, requestOptions: RequestOptions?) -> PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```swift +import Foundation +import Api + +private func main() async throws { + let client = ApiClient() + + _ = try await client.createPlant(request: .init( + species: "species", + family: "family", + genus: "genus", + commonName: "commonName", + wateringFrequency: .daily, + sunExposure: .full + )) +} + +try await main() +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `Requests.PlantPost` + +
+
+ +
+
+ +**requestOptions:** `RequestOptions?` — Additional options for configuring the request, such as custom headers or timeout settings. + +
+
+
+
+ + +
+
+
+ +
client.createTree(request: TreeRecord, requestOptions: RequestOptions?) -> TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```swift +import Foundation +import Api + +private func main() async throws { + let client = ApiClient() + + _ = try await client.createTree(request: TreeRecord( + id: "id", + treeName: "treeName", + treeSpecies: "treeSpecies" + )) +} + +try await main() +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TreeRecord` + +
+
+ +
+
+ +**requestOptions:** `RequestOptions?` — Additional options for configuring the request, such as custom headers or timeout settings. + +
+
+
+
+ + +
+
+
+ diff --git a/seed/swift-sdk/allof/Snippets/Example10.swift b/seed/swift-sdk/allof/Snippets/Example10.swift new file mode 100644 index 000000000000..cf463d2ad281 --- /dev/null +++ b/seed/swift-sdk/allof/Snippets/Example10.swift @@ -0,0 +1,15 @@ +import Foundation +import Api + +private func main() async throws { + let client = ApiClient(baseURL: "https://api.fern.com") + + _ = try await client.createPlant(request: .init( + species: "species", + family: "family", + genus: "genus", + sunExposure: .full + )) +} + +try await main() diff --git a/seed/swift-sdk/allof/Snippets/Example11.swift b/seed/swift-sdk/allof/Snippets/Example11.swift new file mode 100644 index 000000000000..1f87027a7956 --- /dev/null +++ b/seed/swift-sdk/allof/Snippets/Example11.swift @@ -0,0 +1,19 @@ +import Foundation +import Api + +private func main() async throws { + let client = ApiClient(baseURL: "https://api.fern.com") + + _ = try await client.createPlant(request: .init( + commonName: "commonName", + wateringFrequency: .daily, + species: "species", + family: "family", + genus: "genus", + sunExposure: .full, + plantedAt: CalendarDate("2023-01-15")!, + soilType: "soilType" + )) +} + +try await main() diff --git a/seed/swift-sdk/allof/Snippets/Example12.swift b/seed/swift-sdk/allof/Snippets/Example12.swift new file mode 100644 index 000000000000..744df05f9b07 --- /dev/null +++ b/seed/swift-sdk/allof/Snippets/Example12.swift @@ -0,0 +1,12 @@ +import Foundation +import Api + +private func main() async throws { + let client = ApiClient(baseURL: "https://api.fern.com") + + _ = try await client.createTree(request: TreeRecord( + id: "id" + )) +} + +try await main() diff --git a/seed/swift-sdk/allof/Snippets/Example13.swift b/seed/swift-sdk/allof/Snippets/Example13.swift new file mode 100644 index 000000000000..6b7e3c73762c --- /dev/null +++ b/seed/swift-sdk/allof/Snippets/Example13.swift @@ -0,0 +1,17 @@ +import Foundation +import Api + +private func main() async throws { + let client = ApiClient(baseURL: "https://api.fern.com") + + _ = try await client.createTree(request: TreeRecord( + treeSpecies: "treeSpecies", + heightInFeet: 1.1, + id: "id", + treeName: "treeName", + treeDescription: "treeDescription", + plantedDate: CalendarDate("2023-01-15")! + )) +} + +try await main() diff --git a/seed/swift-sdk/allof/Sources/ApiClient.swift b/seed/swift-sdk/allof/Sources/ApiClient.swift index 3bb0c30a67c5..4c4861c3b599 100644 --- a/seed/swift-sdk/allof/Sources/ApiClient.swift +++ b/seed/swift-sdk/allof/Sources/ApiClient.swift @@ -101,4 +101,30 @@ public final class ApiClient: Sendable { responseType: Organization.self ) } + + /// Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + /// + /// - Parameter requestOptions: Additional options for configuring the request, such as custom headers or timeout settings. + public func createPlant(request: Requests.PlantPost, requestOptions: RequestOptions? = nil) async throws -> PlantStrict { + return try await httpClient.performRequest( + method: .post, + path: "/plants", + body: request, + requestOptions: requestOptions, + responseType: PlantStrict.self + ) + } + + /// Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + /// + /// - Parameter requestOptions: Additional options for configuring the request, such as custom headers or timeout settings. + public func createTree(request: TreeRecord, requestOptions: RequestOptions? = nil) async throws -> TreeRecord { + return try await httpClient.performRequest( + method: .post, + path: "/trees", + body: request, + requestOptions: requestOptions, + responseType: TreeRecord.self + ) + } } \ No newline at end of file diff --git a/seed/swift-sdk/allof/Sources/Requests/Requests+PlantPost.swift b/seed/swift-sdk/allof/Sources/Requests/Requests+PlantPost.swift new file mode 100644 index 000000000000..30a65cd1ee0b --- /dev/null +++ b/seed/swift-sdk/allof/Sources/Requests/Requests+PlantPost.swift @@ -0,0 +1,83 @@ +import Foundation + +extension Requests { + public struct PlantPost: Codable, Hashable, Sendable { + /// The common name of the plant. + public let commonName: String? + public let wateringFrequency: PlantBaseWateringFrequency? + /// The botanical species name. + public let species: String + /// The botanical family. + public let family: String + /// The botanical genus. + public let genus: String + /// Required sun exposure level. + public let sunExposure: PlantPostSunExposure + /// Date the plant was planted. + public let plantedAt: CalendarDate? + /// Preferred soil type. + public let soilType: String? + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + commonName: String? = nil, + wateringFrequency: PlantBaseWateringFrequency? = nil, + species: String, + family: String, + genus: String, + sunExposure: PlantPostSunExposure, + plantedAt: CalendarDate? = nil, + soilType: String? = nil, + additionalProperties: [String: JSONValue] = .init() + ) { + self.commonName = commonName + self.wateringFrequency = wateringFrequency + self.species = species + self.family = family + self.genus = genus + self.sunExposure = sunExposure + self.plantedAt = plantedAt + self.soilType = soilType + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.commonName = try container.decodeIfPresent(String.self, forKey: .commonName) + self.wateringFrequency = try container.decodeIfPresent(PlantBaseWateringFrequency.self, forKey: .wateringFrequency) + self.species = try container.decode(String.self, forKey: .species) + self.family = try container.decode(String.self, forKey: .family) + self.genus = try container.decode(String.self, forKey: .genus) + self.sunExposure = try container.decode(PlantPostSunExposure.self, forKey: .sunExposure) + self.plantedAt = try container.decodeIfPresent(CalendarDate.self, forKey: .plantedAt) + self.soilType = try container.decodeIfPresent(String.self, forKey: .soilType) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encodeIfPresent(self.commonName, forKey: .commonName) + try container.encodeIfPresent(self.wateringFrequency, forKey: .wateringFrequency) + try container.encode(self.species, forKey: .species) + try container.encode(self.family, forKey: .family) + try container.encode(self.genus, forKey: .genus) + try container.encode(self.sunExposure, forKey: .sunExposure) + try container.encodeIfPresent(self.plantedAt, forKey: .plantedAt) + try container.encodeIfPresent(self.soilType, forKey: .soilType) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case commonName + case wateringFrequency + case species + case family + case genus + case sunExposure + case plantedAt + case soilType + } + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof/Sources/Schemas/PlantBase.swift b/seed/swift-sdk/allof/Sources/Schemas/PlantBase.swift new file mode 100644 index 000000000000..83e47413fffb --- /dev/null +++ b/seed/swift-sdk/allof/Sources/Schemas/PlantBase.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct PlantBase: Codable, Hashable, Sendable { + /// The botanical species name. + public let species: String + /// The botanical family. + public let family: String + /// The botanical genus. + public let genus: String + /// The common name of the plant. + public let commonName: String? + public let wateringFrequency: PlantBaseWateringFrequency? + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + species: String, + family: String, + genus: String, + commonName: String? = nil, + wateringFrequency: PlantBaseWateringFrequency? = nil, + additionalProperties: [String: JSONValue] = .init() + ) { + self.species = species + self.family = family + self.genus = genus + self.commonName = commonName + self.wateringFrequency = wateringFrequency + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.species = try container.decode(String.self, forKey: .species) + self.family = try container.decode(String.self, forKey: .family) + self.genus = try container.decode(String.self, forKey: .genus) + self.commonName = try container.decodeIfPresent(String.self, forKey: .commonName) + self.wateringFrequency = try container.decodeIfPresent(PlantBaseWateringFrequency.self, forKey: .wateringFrequency) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encode(self.species, forKey: .species) + try container.encode(self.family, forKey: .family) + try container.encode(self.genus, forKey: .genus) + try container.encodeIfPresent(self.commonName, forKey: .commonName) + try container.encodeIfPresent(self.wateringFrequency, forKey: .wateringFrequency) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case species + case family + case genus + case commonName + case wateringFrequency + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof/Sources/Schemas/PlantBaseWateringFrequency.swift b/seed/swift-sdk/allof/Sources/Schemas/PlantBaseWateringFrequency.swift new file mode 100644 index 000000000000..0fdbe24ce526 --- /dev/null +++ b/seed/swift-sdk/allof/Sources/Schemas/PlantBaseWateringFrequency.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum PlantBaseWateringFrequency: String, Codable, Hashable, CaseIterable, Sendable { + case daily + case weekly + case biweekly + case monthly +} \ No newline at end of file diff --git a/seed/swift-sdk/allof/Sources/Schemas/PlantPostSunExposure.swift b/seed/swift-sdk/allof/Sources/Schemas/PlantPostSunExposure.swift new file mode 100644 index 000000000000..dceb046b43aa --- /dev/null +++ b/seed/swift-sdk/allof/Sources/Schemas/PlantPostSunExposure.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Required sun exposure level. +public enum PlantPostSunExposure: String, Codable, Hashable, CaseIterable, Sendable { + case full + case partial + case shade +} \ No newline at end of file diff --git a/seed/swift-sdk/allof/Sources/Schemas/PlantStrict.swift b/seed/swift-sdk/allof/Sources/Schemas/PlantStrict.swift new file mode 100644 index 000000000000..3bda95c55a56 --- /dev/null +++ b/seed/swift-sdk/allof/Sources/Schemas/PlantStrict.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct PlantStrict: Codable, Hashable, Sendable { + /// The botanical species name. + public let species: String + /// The botanical family. + public let family: String + /// The botanical genus. + public let genus: String + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + species: String, + family: String, + genus: String, + additionalProperties: [String: JSONValue] = .init() + ) { + self.species = species + self.family = family + self.genus = genus + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.species = try container.decode(String.self, forKey: .species) + self.family = try container.decode(String.self, forKey: .family) + self.genus = try container.decode(String.self, forKey: .genus) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encode(self.species, forKey: .species) + try container.encode(self.family, forKey: .family) + try container.encode(self.genus, forKey: .genus) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case species + case family + case genus + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof/Sources/Schemas/TreeBase.swift b/seed/swift-sdk/allof/Sources/Schemas/TreeBase.swift new file mode 100644 index 000000000000..081bd4abd095 --- /dev/null +++ b/seed/swift-sdk/allof/Sources/Schemas/TreeBase.swift @@ -0,0 +1,61 @@ +import Foundation + +public struct TreeBase: Codable, Hashable, Sendable { + /// Unique tree identifier. + public let id: String + /// Display name of the tree. + public let treeName: String? + /// A description of the tree. + public let treeDescription: String? + /// The species of tree. + public let treeSpecies: String? + /// Height of the tree in feet. + public let heightInFeet: Double? + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + id: String, + treeName: String? = nil, + treeDescription: String? = nil, + treeSpecies: String? = nil, + heightInFeet: Double? = nil, + additionalProperties: [String: JSONValue] = .init() + ) { + self.id = id + self.treeName = treeName + self.treeDescription = treeDescription + self.treeSpecies = treeSpecies + self.heightInFeet = heightInFeet + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.treeName = try container.decodeIfPresent(String.self, forKey: .treeName) + self.treeDescription = try container.decodeIfPresent(String.self, forKey: .treeDescription) + self.treeSpecies = try container.decodeIfPresent(String.self, forKey: .treeSpecies) + self.heightInFeet = try container.decodeIfPresent(Double.self, forKey: .heightInFeet) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encode(self.id, forKey: .id) + try container.encodeIfPresent(self.treeName, forKey: .treeName) + try container.encodeIfPresent(self.treeDescription, forKey: .treeDescription) + try container.encodeIfPresent(self.treeSpecies, forKey: .treeSpecies) + try container.encodeIfPresent(self.heightInFeet, forKey: .heightInFeet) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case id + case treeName + case treeDescription + case treeSpecies + case heightInFeet + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof/Sources/Schemas/TreeDescribable.swift b/seed/swift-sdk/allof/Sources/Schemas/TreeDescribable.swift new file mode 100644 index 000000000000..df29e1c902a5 --- /dev/null +++ b/seed/swift-sdk/allof/Sources/Schemas/TreeDescribable.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct TreeDescribable: Codable, Hashable, Sendable { + /// Display name of the tree. + public let treeName: String? + /// A description of the tree. + public let treeDescription: String? + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + treeName: String? = nil, + treeDescription: String? = nil, + additionalProperties: [String: JSONValue] = .init() + ) { + self.treeName = treeName + self.treeDescription = treeDescription + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.treeName = try container.decodeIfPresent(String.self, forKey: .treeName) + self.treeDescription = try container.decodeIfPresent(String.self, forKey: .treeDescription) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encodeIfPresent(self.treeName, forKey: .treeName) + try container.encodeIfPresent(self.treeDescription, forKey: .treeDescription) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case treeName + case treeDescription + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof/Sources/Schemas/TreeIdentifiable.swift b/seed/swift-sdk/allof/Sources/Schemas/TreeIdentifiable.swift new file mode 100644 index 000000000000..412fdc8ae04a --- /dev/null +++ b/seed/swift-sdk/allof/Sources/Schemas/TreeIdentifiable.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct TreeIdentifiable: Codable, Hashable, Sendable { + /// Unique tree identifier. + public let id: String + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + id: String, + additionalProperties: [String: JSONValue] = .init() + ) { + self.id = id + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encode(self.id, forKey: .id) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case id + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof/Sources/Schemas/TreeRecord.swift b/seed/swift-sdk/allof/Sources/Schemas/TreeRecord.swift new file mode 100644 index 000000000000..ad61b0632b2e --- /dev/null +++ b/seed/swift-sdk/allof/Sources/Schemas/TreeRecord.swift @@ -0,0 +1,68 @@ +import Foundation + +public struct TreeRecord: Codable, Hashable, Sendable { + /// The species of tree. + public let treeSpecies: String? + /// Height of the tree in feet. + public let heightInFeet: Double? + /// Unique tree identifier. + public let id: String + /// Display name of the tree. + public let treeName: String? + /// A description of the tree. + public let treeDescription: String? + /// Date the tree was planted. + public let plantedDate: CalendarDate? + /// Additional properties that are not explicitly defined in the schema + public let additionalProperties: [String: JSONValue] + + public init( + treeSpecies: String? = nil, + heightInFeet: Double? = nil, + id: String, + treeName: String? = nil, + treeDescription: String? = nil, + plantedDate: CalendarDate? = nil, + additionalProperties: [String: JSONValue] = .init() + ) { + self.treeSpecies = treeSpecies + self.heightInFeet = heightInFeet + self.id = id + self.treeName = treeName + self.treeDescription = treeDescription + self.plantedDate = plantedDate + self.additionalProperties = additionalProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.treeSpecies = try container.decodeIfPresent(String.self, forKey: .treeSpecies) + self.heightInFeet = try container.decodeIfPresent(Double.self, forKey: .heightInFeet) + self.id = try container.decode(String.self, forKey: .id) + self.treeName = try container.decodeIfPresent(String.self, forKey: .treeName) + self.treeDescription = try container.decodeIfPresent(String.self, forKey: .treeDescription) + self.plantedDate = try container.decodeIfPresent(CalendarDate.self, forKey: .plantedDate) + self.additionalProperties = try decoder.decodeAdditionalProperties(using: CodingKeys.self) + } + + public func encode(to encoder: Encoder) throws -> Void { + var container = encoder.container(keyedBy: CodingKeys.self) + try encoder.encodeAdditionalProperties(self.additionalProperties) + try container.encodeIfPresent(self.treeSpecies, forKey: .treeSpecies) + try container.encodeIfPresent(self.heightInFeet, forKey: .heightInFeet) + try container.encode(self.id, forKey: .id) + try container.encodeIfPresent(self.treeName, forKey: .treeName) + try container.encodeIfPresent(self.treeDescription, forKey: .treeDescription) + try container.encodeIfPresent(self.plantedDate, forKey: .plantedDate) + } + + /// Keys for encoding/decoding struct properties. + enum CodingKeys: String, CodingKey, CaseIterable { + case treeSpecies + case heightInFeet + case id + case treeName + case treeDescription + case plantedDate + } +} \ No newline at end of file diff --git a/seed/swift-sdk/allof/reference.md b/seed/swift-sdk/allof/reference.md index 75ea9a025cca..7080138710b2 100644 --- a/seed/swift-sdk/allof/reference.md +++ b/seed/swift-sdk/allof/reference.md @@ -263,3 +263,152 @@ try await main() +
client.createPlant(request: Requests.PlantPost, requestOptions: RequestOptions?) -> PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```swift +import Foundation +import Api + +private func main() async throws { + let client = ApiClient() + + _ = try await client.createPlant(request: .init( + species: "species", + family: "family", + genus: "genus", + sunExposure: .full + )) +} + +try await main() +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `Requests.PlantPost` + +
+
+ +
+
+ +**requestOptions:** `RequestOptions?` — Additional options for configuring the request, such as custom headers or timeout settings. + +
+
+
+
+ + +
+
+
+ +
client.createTree(request: TreeRecord, requestOptions: RequestOptions?) -> TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```swift +import Foundation +import Api + +private func main() async throws { + let client = ApiClient() + + _ = try await client.createTree(request: TreeRecord( + id: "id" + )) +} + +try await main() +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TreeRecord` + +
+
+ +
+
+ +**requestOptions:** `RequestOptions?` — Additional options for configuring the request, such as custom headers or timeout settings. + +
+
+
+
+ + +
+
+
+ diff --git a/seed/ts-sdk/allof-inline/reference.md b/seed/ts-sdk/allof-inline/reference.md index 6d75591df79d..9924ff8bcc28 100644 --- a/seed/ts-sdk/allof-inline/reference.md +++ b/seed/ts-sdk/allof-inline/reference.md @@ -223,3 +223,140 @@ await client.getOrganization(); +
client.createPlant({ ...params }) -> SeedApi.PlantStrict +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.createPlant({ + species: "species", + family: "family", + genus: "genus", + commonName: "commonName", + wateringFrequency: "daily", + sunExposure: "full" +}); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `SeedApi.PlantPost` + +
+
+ +
+
+ +**requestOptions:** `SeedApiClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ +
client.createTree({ ...params }) -> SeedApi.TreeRecord +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.createTree({ + id: "id", + treeName: "treeName", + treeSpecies: "treeSpecies" +}); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `SeedApi.TreeRecord` + +
+
+ +
+
+ +**requestOptions:** `SeedApiClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/ts-sdk/allof-inline/snippet.json b/seed/ts-sdk/allof-inline/snippet.json index 8b75c9d2992f..160eaaa6636f 100644 --- a/seed/ts-sdk/allof-inline/snippet.json +++ b/seed/ts-sdk/allof-inline/snippet.json @@ -54,6 +54,28 @@ "type": "typescript", "client": "import { SeedApiClient } from \"@fern/allof-inline\";\n\nconst client = new SeedApiClient;\nawait client.getOrganization();\n" } + }, + { + "id": { + "path": "/plants", + "method": "POST", + "identifier_override": "endpoint_.createPlant" + }, + "snippet": { + "type": "typescript", + "client": "import { SeedApiClient } from \"@fern/allof-inline\";\n\nconst client = new SeedApiClient;\nawait client.createPlant({\n species: \"species\",\n family: \"family\",\n genus: \"genus\",\n commonName: \"commonName\",\n wateringFrequency: \"daily\",\n sunExposure: \"full\"\n});\n" + } + }, + { + "id": { + "path": "/trees", + "method": "POST", + "identifier_override": "endpoint_.createTree" + }, + "snippet": { + "type": "typescript", + "client": "import { SeedApiClient } from \"@fern/allof-inline\";\n\nconst client = new SeedApiClient;\nawait client.createTree({\n id: \"id\",\n treeName: \"treeName\",\n treeSpecies: \"treeSpecies\"\n});\n" + } } ], "types": {} diff --git a/seed/ts-sdk/allof-inline/src/Client.ts b/seed/ts-sdk/allof-inline/src/Client.ts index ba5afd81e1a0..21789920ff68 100644 --- a/seed/ts-sdk/allof-inline/src/Client.ts +++ b/seed/ts-sdk/allof-inline/src/Client.ts @@ -275,6 +275,127 @@ export class SeedApiClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/organizations"); } + /** + * Tests three-level allOf chain where a parent schema itself uses allOf with $ref elements. The grandparent's properties must be resolved through the nested $ref. + * + * @param {SeedApi.PlantPost} request + * @param {SeedApiClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.createPlant({ + * species: "species", + * family: "family", + * genus: "genus", + * commonName: "commonName", + * wateringFrequency: "daily", + * sunExposure: "full" + * }) + */ + public createPlant( + request: SeedApi.PlantPost, + requestOptions?: SeedApiClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__createPlant(request, requestOptions)); + } + + private async __createPlant( + request: SeedApi.PlantPost, + requestOptions?: SeedApiClient.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)) ?? + environments.SeedApiEnvironment.Default, + "plants", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + requestType: "json", + body: request, + 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 SeedApi.PlantStrict, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.SeedApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/plants"); + } + + /** + * Tests that when a parent's allOf contains multiple $ref entries, all of them are resolved and their properties merged. + * + * @param {SeedApi.TreeRecord} request + * @param {SeedApiClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.createTree({ + * id: "id", + * treeName: "treeName", + * treeSpecies: "treeSpecies" + * }) + */ + public createTree( + request: SeedApi.TreeRecord, + requestOptions?: SeedApiClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__createTree(request, requestOptions)); + } + + private async __createTree( + request: SeedApi.TreeRecord, + requestOptions?: SeedApiClient.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)) ?? + environments.SeedApiEnvironment.Default, + "trees", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + requestType: "json", + body: request, + 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 SeedApi.TreeRecord, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.SeedApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/trees"); + } + /** * Make a passthrough request using the SDK's configured auth, retry, logging, etc. * This is useful for making requests to endpoints not yet supported in the SDK. diff --git a/seed/ts-sdk/allof-inline/src/api/client/requests/PlantPost.ts b/seed/ts-sdk/allof-inline/src/api/client/requests/PlantPost.ts new file mode 100644 index 000000000000..583b8387c8c9 --- /dev/null +++ b/seed/ts-sdk/allof-inline/src/api/client/requests/PlantPost.ts @@ -0,0 +1,47 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * species: "species", + * family: "family", + * genus: "genus", + * commonName: "commonName", + * wateringFrequency: "daily", + * sunExposure: "full" + * } + */ +export interface PlantPost { + /** The botanical species name. */ + species: string; + /** The botanical family. */ + family: string; + /** The botanical genus. */ + genus: string; + /** The common name of the plant. */ + commonName: string; + wateringFrequency: PlantPost.WateringFrequency; + /** Required sun exposure level. */ + sunExposure: PlantPost.SunExposure; + /** Date the plant was planted. */ + plantedAt?: string; + /** Preferred soil type. */ + soilType?: string; +} + +export namespace PlantPost { + export const WateringFrequency = { + Daily: "daily", + Weekly: "weekly", + Biweekly: "biweekly", + Monthly: "monthly", + } as const; + export type WateringFrequency = (typeof WateringFrequency)[keyof typeof WateringFrequency]; + /** Required sun exposure level. */ + export const SunExposure = { + Full: "full", + Partial: "partial", + Shade: "shade", + } as const; + export type SunExposure = (typeof SunExposure)[keyof typeof SunExposure]; +} diff --git a/seed/ts-sdk/allof-inline/src/api/client/requests/index.ts b/seed/ts-sdk/allof-inline/src/api/client/requests/index.ts index c8c22b703233..8c029b7dd67c 100644 --- a/seed/ts-sdk/allof-inline/src/api/client/requests/index.ts +++ b/seed/ts-sdk/allof-inline/src/api/client/requests/index.ts @@ -1,2 +1,3 @@ +export { PlantPost } from "./PlantPost.js"; export { RuleCreateRequest } from "./RuleCreateRequest.js"; export type { SearchRuleTypesRequest } from "./SearchRuleTypesRequest.js"; diff --git a/seed/ts-sdk/allof-inline/src/api/types/PlantBase.ts b/seed/ts-sdk/allof-inline/src/api/types/PlantBase.ts new file mode 100644 index 000000000000..9f65df6b50b2 --- /dev/null +++ b/seed/ts-sdk/allof-inline/src/api/types/PlantBase.ts @@ -0,0 +1,23 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface PlantBase { + /** The botanical species name. */ + species: string; + /** The botanical family. */ + family: string; + /** The botanical genus. */ + genus: string; + /** The common name of the plant. */ + commonName?: string | undefined; + wateringFrequency?: PlantBase.WateringFrequency | undefined; +} + +export namespace PlantBase { + export const WateringFrequency = { + Daily: "daily", + Weekly: "weekly", + Biweekly: "biweekly", + Monthly: "monthly", + } as const; + export type WateringFrequency = (typeof WateringFrequency)[keyof typeof WateringFrequency]; +} diff --git a/seed/ts-sdk/allof-inline/src/api/types/PlantStrict.ts b/seed/ts-sdk/allof-inline/src/api/types/PlantStrict.ts new file mode 100644 index 000000000000..1c607c5fbd30 --- /dev/null +++ b/seed/ts-sdk/allof-inline/src/api/types/PlantStrict.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface PlantStrict { + /** The botanical species name. */ + species: string; + /** The botanical family. */ + family: string; + /** The botanical genus. */ + genus: string; +} diff --git a/seed/ts-sdk/allof-inline/src/api/types/TreeBase.ts b/seed/ts-sdk/allof-inline/src/api/types/TreeBase.ts new file mode 100644 index 000000000000..1d34d890116b --- /dev/null +++ b/seed/ts-sdk/allof-inline/src/api/types/TreeBase.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TreeBase { + /** Unique tree identifier. */ + id: string; + /** Display name of the tree. */ + treeName?: string | undefined; + /** A description of the tree. */ + treeDescription?: string | undefined; + /** The species of tree. */ + treeSpecies?: string | undefined; + /** Height of the tree in feet. */ + heightInFeet?: number | undefined; +} diff --git a/seed/ts-sdk/allof-inline/src/api/types/TreeDescribable.ts b/seed/ts-sdk/allof-inline/src/api/types/TreeDescribable.ts new file mode 100644 index 000000000000..607ab65f3516 --- /dev/null +++ b/seed/ts-sdk/allof-inline/src/api/types/TreeDescribable.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TreeDescribable { + /** Display name of the tree. */ + treeName?: string | undefined; + /** A description of the tree. */ + treeDescription?: string | undefined; +} diff --git a/seed/ts-sdk/allof-inline/src/api/types/TreeIdentifiable.ts b/seed/ts-sdk/allof-inline/src/api/types/TreeIdentifiable.ts new file mode 100644 index 000000000000..9f6f9c68c699 --- /dev/null +++ b/seed/ts-sdk/allof-inline/src/api/types/TreeIdentifiable.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TreeIdentifiable { + /** Unique tree identifier. */ + id: string; +} diff --git a/seed/ts-sdk/allof-inline/src/api/types/TreeRecord.ts b/seed/ts-sdk/allof-inline/src/api/types/TreeRecord.ts new file mode 100644 index 000000000000..eff8641866bb --- /dev/null +++ b/seed/ts-sdk/allof-inline/src/api/types/TreeRecord.ts @@ -0,0 +1,16 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface TreeRecord { + /** Unique tree identifier. */ + id: string; + /** Display name of the tree. */ + treeName: string; + /** A description of the tree. */ + treeDescription?: string | undefined; + /** The species of tree. */ + treeSpecies: string; + /** Height of the tree in feet. */ + heightInFeet?: number | undefined; + /** Date the tree was planted. */ + plantedDate?: string | undefined; +} diff --git a/seed/ts-sdk/allof-inline/src/api/types/index.ts b/seed/ts-sdk/allof-inline/src/api/types/index.ts index ae8a133ce81f..fcceebd8605a 100644 --- a/seed/ts-sdk/allof-inline/src/api/types/index.ts +++ b/seed/ts-sdk/allof-inline/src/api/types/index.ts @@ -7,9 +7,15 @@ export * from "./Identifiable.js"; export * from "./Organization.js"; export * from "./PaginatedResult.js"; export * from "./PagingCursors.js"; +export * from "./PlantBase.js"; +export * from "./PlantStrict.js"; export * from "./RuleExecutionContext.js"; export * from "./RuleResponse.js"; export * from "./RuleType.js"; export * from "./RuleTypeSearchResponse.js"; +export * from "./TreeBase.js"; +export * from "./TreeDescribable.js"; +export * from "./TreeIdentifiable.js"; +export * from "./TreeRecord.js"; export * from "./User.js"; export * from "./UserSearchResponse.js"; diff --git a/seed/ts-sdk/allof-inline/tests/wire/main.test.ts b/seed/ts-sdk/allof-inline/tests/wire/main.test.ts index 322f94682c9e..ca28f596a942 100644 --- a/seed/ts-sdk/allof-inline/tests/wire/main.test.ts +++ b/seed/ts-sdk/allof-inline/tests/wire/main.test.ts @@ -88,4 +88,67 @@ describe("SeedApiClient", () => { const response = await client.getOrganization(); expect(response).toEqual(rawResponseBody); }); + + test("createPlant", async () => { + const server = mockServerPool.createServer(); + const client = new SeedApiClient({ maxRetries: 0, environment: server.baseUrl }); + const rawRequestBody = { + species: "species", + family: "family", + genus: "genus", + commonName: "commonName", + wateringFrequency: "daily", + sunExposure: "full", + }; + const rawResponseBody = { species: "species", family: "family", genus: "genus" }; + + server + .mockEndpoint() + .post("/plants") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.createPlant({ + species: "species", + family: "family", + genus: "genus", + commonName: "commonName", + wateringFrequency: "daily", + sunExposure: "full", + }); + expect(response).toEqual(rawResponseBody); + }); + + test("createTree", async () => { + const server = mockServerPool.createServer(); + const client = new SeedApiClient({ maxRetries: 0, environment: server.baseUrl }); + const rawRequestBody = { id: "id", treeName: "treeName", treeSpecies: "treeSpecies" }; + const rawResponseBody = { + id: "id", + treeName: "treeName", + treeDescription: "treeDescription", + treeSpecies: "treeSpecies", + heightInFeet: 1.1, + plantedDate: "2023-01-15", + }; + + server + .mockEndpoint() + .post("/trees") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.createTree({ + id: "id", + treeName: "treeName", + treeSpecies: "treeSpecies", + }); + expect(response).toEqual(rawResponseBody); + }); }); diff --git a/seed/ts-sdk/allof/.fern/metadata.json b/seed/ts-sdk/allof/.fern/metadata.json index 9ea68298004b..5789995ae525 100644 --- a/seed/ts-sdk/allof/.fern/metadata.json +++ b/seed/ts-sdk/allof/.fern/metadata.json @@ -3,7 +3,8 @@ "generatorName": "fernapi/fern-typescript-sdk", "generatorVersion": "local", "originGitCommit": "DUMMY", - "invokedBy": "manual", + "invokedBy": "ci", "requestedVersion": "0.0.1", + "ciProvider": "github", "sdkVersion": "0.0.1" } From 24ea87845536b0b84823e1f32dd2025f9ab6db41 Mon Sep 17 00:00:00 2001 From: Anar Kafkas <36949216+kafkas@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:31:20 +0300 Subject: [PATCH 11/14] fix(cli): localize translated API references in docs (#16190) * Implement `applyTranslatedApiTitlesToNavTree` * Preview path * Update docs def resolver * Add exports * Update publish path * Add changelog entry * Add tests for `applyTranslatedApiTitlesToNavTree` * Trigger CI --------- Co-authored-by: Fern Support Co-authored-by: Danny Sheridan <83524670+dannysheridan@users.noreply.github.com> --- .../fix-translated-api-references.yml | 3 + packages/cli/docs-preview/src/previewDocs.ts | 74 +++- .../docs-preview/src/runAppPreviewServer.ts | 49 ++- .../src/DocsDefinitionResolver.ts | 236 ++++++++++- .../applyTranslatedApiTitlesToNavTree.test.ts | 245 +++++++++++ .../src/applyTranslatedApiTitlesToNavTree.ts | 137 ++++++ packages/cli/docs-resolver/src/index.ts | 9 +- .../src/publishDocs.ts | 396 ++++++++++++------ 8 files changed, 998 insertions(+), 151 deletions(-) create mode 100644 packages/cli/cli/changes/unreleased/fix-translated-api-references.yml create mode 100644 packages/cli/docs-resolver/src/__test__/applyTranslatedApiTitlesToNavTree.test.ts create mode 100644 packages/cli/docs-resolver/src/applyTranslatedApiTitlesToNavTree.ts diff --git a/packages/cli/cli/changes/unreleased/fix-translated-api-references.yml b/packages/cli/cli/changes/unreleased/fix-translated-api-references.yml new file mode 100644 index 000000000000..edd37083d1bf --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-translated-api-references.yml @@ -0,0 +1,3 @@ +- summary: | + Fix localized docs builds so translated OpenAPI specs are used for API reference content and navigation titles. + type: fix diff --git a/packages/cli/docs-preview/src/previewDocs.ts b/packages/cli/docs-preview/src/previewDocs.ts index 540df585f20d..bcadd700450a 100644 --- a/packages/cli/docs-preview/src/previewDocs.ts +++ b/packages/cli/docs-preview/src/previewDocs.ts @@ -13,6 +13,7 @@ import { DocsDefinitionResolver, filterOssWorkspaces, stitchGlobalTheme, + type TranslatedApiSpec, type TranslationNavigationOverlay } from "@fern-api/docs-resolver"; import { @@ -151,6 +152,12 @@ export interface PreviewDocsResult { * Absolute path to the fern docs workspace folder. */ docsWorkspacePath: AbsoluteFilePath; + /** + * Per-locale translated API definitions, keyed by locale then by the base + * `apiDefinitionId` (shared with the default-locale definition) so they can be + * spliced into a per-locale docs definition's `apis` without touching the nav tree. + */ + translatedApiDefinitions: Record> | undefined; } export async function getPreviewDocsDefinition({ @@ -318,7 +325,8 @@ export async function getPreviewDocsDefinition({ translationPages: previousPreviewResult.translationPages, translationNavigationOverlays: previousPreviewResult.translationNavigationOverlays, collectedFileIds: previousPreviewResult.collectedFileIds, - docsWorkspacePath: previousPreviewResult.docsWorkspacePath + docsWorkspacePath: previousPreviewResult.docsWorkspacePath, + translatedApiDefinitions: previousPreviewResult.translatedApiDefinitions }; } } @@ -355,7 +363,8 @@ export async function getPreviewDocsDefinition({ }; }), registerApi: async (opts) => apiCollector.addReferencedAPI(opts), - targetAudiences: undefined + targetAudiences: undefined, + buildTranslatedApiDefinitions: true }); const writeDocsDefinition = await resolver.resolve(); @@ -410,15 +419,74 @@ export async function getPreviewDocsDefinition({ const translationNavigationOverlays = resolver.getTranslationNavigationOverlays(); const collectedFileIds = resolver.getCollectedFileIds(); + const translatedApiSpecs = resolver.getTranslatedApiSpecs(); + let translatedApiDefinitions: Record> | undefined; + if (translatedApiSpecs.size > 0) { + translatedApiDefinitions = {}; + for (const [locale, byApiId] of translatedApiSpecs) { + const localeApis: Record = {}; + for (const [apiDefinitionId, spec] of byApiId) { + try { + localeApis[apiDefinitionId] = convertTranslatedIrToReadApi(spec, apiDefinitionId, context); + } catch (error) { + context.logger.warn( + `Failed to convert translated API definition "${apiDefinitionId}" for locale "${locale}": ${extractErrorMessage( + error + )}. Falling back to the default-locale API.` + ); + } + } + if (Object.keys(localeApis).length > 0) { + translatedApiDefinitions[locale] = localeApis; + } + } + if (Object.keys(translatedApiDefinitions).length === 0) { + translatedApiDefinitions = undefined; + } + } + return { docsDefinition, translationPages, translationNavigationOverlays, collectedFileIds, - docsWorkspacePath: docsWorkspace.absoluteFilePath + docsWorkspacePath: docsWorkspace.absoluteFilePath, + translatedApiDefinitions }; } +/** + * Converts a translated API IR into a V1 read API definition, reusing the base + * `apiDefinitionId` instead of minting a fresh one so the per-locale navigation + * tree resolves to the translated API. + */ +function convertTranslatedIrToReadApi( + spec: TranslatedApiSpec, + apiDefinitionId: string, + context: TaskContext +): APIV1Read.ApiDefinition { + const dbApiDefinition = convertAPIDefinitionToDb( + convertIrToFdrApi({ + ir: spec.ir, + snippetsConfig: spec.snippetsConfig, + playgroundConfig: spec.playgroundConfig, + graphqlOperations: spec.graphqlOperations ?? {}, + graphqlTypes: spec.graphqlTypes ?? {}, + context, + apiNameOverride: spec.apiName + }), + FdrAPI.ApiDefinitionId(apiDefinitionId), + new SDKSnippetHolder({ + snippetsConfigWithSdkId: {}, + snippetsBySdkId: {}, + snippetTemplatesByEndpoint: {}, + snippetTemplatesByEndpointId: {}, + snippetsBySdkIdAndEndpointId: {} + }) + ); + return convertDbAPIDefinitionToRead(dbApiDefinition); +} + async function applyGlobalThemeIfNeeded( docsWorkspace: NonNullable, organization: string, diff --git a/packages/cli/docs-preview/src/runAppPreviewServer.ts b/packages/cli/docs-preview/src/runAppPreviewServer.ts index 1f87424700a3..f6ce01c1482d 100644 --- a/packages/cli/docs-preview/src/runAppPreviewServer.ts +++ b/packages/cli/docs-preview/src/runAppPreviewServer.ts @@ -1,5 +1,6 @@ import { extractErrorMessage } from "@fern-api/core-utils"; import { + applyTranslatedApiTitlesToNavTree, applyTranslatedFrontmatterToNavTree, applyTranslatedNavigationOverlays, getTranslatedAnnouncement, @@ -10,7 +11,7 @@ import { transformAtPrefixImports, wrapWithHttps } from "@fern-api/docs-resolver"; -import { DocsV1Read, DocsV2Read, FernNavigation } from "@fern-api/fdr-sdk"; +import { APIV1Read, DocsV1Read, DocsV2Read, FernNavigation } from "@fern-api/fdr-sdk"; import { AbsoluteFilePath, dirname, @@ -735,21 +736,37 @@ export async function runAppPreviewServer({ result: PreviewDocsResult ): Promise> { const translations = new Map(); - const { docsDefinition, translationPages, translationNavigationOverlays, collectedFileIds, docsWorkspacePath } = - result; - - if (translationPages == null || Object.keys(translationPages).length === 0) { + const { + docsDefinition, + translationPages, + translationNavigationOverlays, + translatedApiDefinitions, + collectedFileIds, + docsWorkspacePath + } = result; + + const hasTranslatedPages = translationPages != null && Object.keys(translationPages).length > 0; + const hasTranslatedApis = translatedApiDefinitions != null && Object.keys(translatedApiDefinitions).length > 0; + if (!hasTranslatedPages && !hasTranslatedApis) { return translations; } const defaultLocale = docsDefinition.config.translations?.defaultLocale; - for (const [locale, localePages] of Object.entries(translationPages)) { + // A locale qualifies if it has translated pages, translated API specs, or both. + const localesToBuild = new Set([ + ...Object.keys(translationPages ?? {}), + ...Object.keys(translatedApiDefinitions ?? {}) + ]); + + for (const locale of localesToBuild) { // Skip the default locale - we use the base definition for that if (locale === defaultLocale) { continue; } + const localePages = translationPages?.[locale] ?? {}; + try { // Locale-aware file loaders that prefer translated snippets when available const resolveLocalePath = async (filepath: AbsoluteFilePath): Promise => { @@ -859,8 +876,23 @@ export async function runAppPreviewServer({ } } + const localeApis = translatedApiDefinitions?.[locale]; + const translatedApis = + localeApis != null ? { ...docsDefinition.apis, ...localeApis } : docsDefinition.apis; + + // Splicing `apis` alone leaves the sidebar in the default language, + // since the nav tree bakes in titles from the base API definition. + if (localeApis != null && updatedRoot != null) { + updatedRoot = applyTranslatedApiTitlesToNavTree( + updatedRoot, + docsDefinition.apis as Record, + localeApis + ); + } + const translatedDefinition: DocsV1Read.DocsDefinition = { ...docsDefinition, + apis: translatedApis, pages: translatedPages, config: { ...docsDefinition.config, @@ -871,7 +903,10 @@ export async function runAppPreviewServer({ }; translations.set(locale, translatedDefinition); - context.logger.debug(`Computed translated definition for locale "${locale}"`); + context.logger.debug( + `Computed translated definition for locale "${locale}"` + + (localeApis != null ? ` (with ${Object.keys(localeApis).length} translated API(s))` : "") + ); } catch (error) { context.logger.warn(`Failed to compute translation for locale "${locale}": ${String(error)}`); } diff --git a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts index e5793f66341a..1ea12edfd2db 100644 --- a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts +++ b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts @@ -1,4 +1,4 @@ -import { GraphQLSpec } from "@fern-api/api-workspace-commons"; +import { GraphQLSpec, type Spec } from "@fern-api/api-workspace-commons"; import { SourceResolverImpl } from "@fern-api/cli-source-resolver"; import { docsYml, parseAudiences, parseDocsConfiguration, WithoutQuestionMarks } from "@fern-api/configuration-loader"; import { @@ -93,7 +93,7 @@ type AsyncOrSync = T | Promise; type UploadFilesFn = (files: FilePathPair[]) => AsyncOrSync; -type RegisterApiFn = (opts: { +export type RegisterApiFn = (opts: { ir: IntermediateRepresentation; snippetsConfig: APIV1Write.SnippetsConfig; playgroundConfig?: PlaygroundConfig; @@ -103,6 +103,29 @@ type RegisterApiFn = (opts: { graphqlTypes?: Record; }) => AsyncOrSync; +/** + * A translated API definition for a single locale, produced from an OpenAPI + * spec under `translations//apis//`. The `ir` is structurally + * identical to the base (English) API definition — only human-readable strings + * (summaries, descriptions, etc.) differ — and is keyed by the SAME + * `apiDefinitionId` as the base so the per-locale navigation tree resolves to it + * without modification. See {@link DocsDefinitionResolver.getTranslatedApiSpecs}. + */ +export interface TranslatedApiSpec { + ir: IntermediateRepresentation; + snippetsConfig: APIV1Write.SnippetsConfig; + playgroundConfig?: PlaygroundConfig; + apiName?: string; + graphqlOperations?: Record; + graphqlTypes?: Record; + /** + * The base API's Fern workspace, reused as-is when registering the translated + * definition for production publishing (it carries the generators.yml config and + * source paths for dynamic snippets / AI examples, which are identical across locales). + */ + workspace?: FernWorkspace; +} + type ConfigureAiChatFn = (opts: { aiChatConfig: DocsV1Write.AIChatConfig | undefined }) => AsyncOrSync; const defaultUploadFiles: UploadFilesFn = (files) => { @@ -127,6 +150,13 @@ export interface DocsDefinitionResolverArgs { uploadFiles?: UploadFilesFn; registerApi?: RegisterApiFn; targetAudiences?: string[]; + /** + * When true, also builds per-locale translated API IRs from OpenAPI specs under + * `translations//apis//`, exposed via + * {@link DocsDefinitionResolver.getTranslatedApiSpecs}. Defaults to false to avoid + * the extra parsing work where translated API references aren't needed. + */ + buildTranslatedApiDefinitions?: boolean; } export class DocsDefinitionResolver { @@ -139,6 +169,7 @@ export class DocsDefinitionResolver { private uploadFiles: UploadFilesFn; private registerApi: RegisterApiFn; private targetAudiences?: string[]; + private buildTranslatedApiDefinitions: boolean; constructor({ domain, @@ -149,7 +180,8 @@ export class DocsDefinitionResolver { editThisPage, uploadFiles = defaultUploadFiles, registerApi = defaultRegisterApi, - targetAudiences + targetAudiences, + buildTranslatedApiDefinitions = false }: DocsDefinitionResolverArgs) { this.domain = domain; this.docsWorkspace = docsWorkspace; @@ -160,6 +192,7 @@ export class DocsDefinitionResolver { this.uploadFiles = uploadFiles; this.registerApi = registerApi; this.targetAudiences = targetAudiences; + this.buildTranslatedApiDefinitions = buildTranslatedApiDefinitions; } #idgen = NodeIdGenerator.init(); @@ -300,6 +333,14 @@ export class DocsDefinitionResolver { return this.docsWorkspace.absoluteFilePath; } + /** + * Returns per-locale translated API definitions, keyed by locale then by the base + * `apiDefinitionId`. Must be called after `resolve()`. + */ + public getTranslatedApiSpecs(): Map> { + return this.translatedApiSpecs; + } + private getDocsTranslationsConfig(): DocsConfigWithTranslations["translations"] { const translations = this.parsedDocsConfig.translations; if (translations == null || translations.length === 0) { @@ -336,8 +377,20 @@ export class DocsDefinitionResolver { graphqlTypes?: Record; tempApiDefinitionId: string; apiReferenceNode: FernNavigation.V1.ApiReferenceNode; + /** + * Translated IRs (one per non-default locale that has a translated spec + * under `translations//apis//`). Resolved into + * {@link translatedApiSpecs} once the real apiDefinitionId is known. + */ + translatedIrsByLocale?: Map; }> = []; private pendingApiCounter = 0; + /** + * Per-locale translated API definitions, keyed by locale then by the base + * `apiDefinitionId`. Populated during {@link resolve} when an API section has + * a translated OpenAPI spec under `translations//apis//`. + */ + private translatedApiSpecs: Map> = new Map(); public async resolve(): Promise { const resolveStartTime = performance.now(); const startMemory = process.memoryUsage(); @@ -555,6 +608,29 @@ export class DocsDefinitionResolver { // Update all apiDefinitionId references in the navigation subtree updateApiDefinitionIdInTree(pending.apiReferenceNode, pending.tempApiDefinitionId, realApiDefinitionId); + + // Key translated IRs by the real apiDefinitionId so consumers can splice + // them into per-locale docs definitions without touching the nav tree. + if (pending.translatedIrsByLocale != null) { + for (const [locale, translatedIr] of pending.translatedIrsByLocale) { + this.resolveLinksInIrDocs(translatedIr, markdownFilesToPathName); + + let localeMap = this.translatedApiSpecs.get(locale); + if (localeMap == null) { + localeMap = new Map(); + this.translatedApiSpecs.set(locale, localeMap); + } + localeMap.set(realApiDefinitionId, { + ir: translatedIr, + snippetsConfig: pending.snippetsConfig, + playgroundConfig: pending.playgroundConfig, + apiName: pending.apiName, + graphqlOperations: pending.graphqlOperations, + graphqlTypes: pending.graphqlTypes, + workspace: pending.workspace + }); + } + } } const deferredTime = performance.now() - deferredStart; this.taskContext.logger.debug(`Processed deferred API registrations in ${deferredTime.toFixed(0)}ms`); @@ -1018,6 +1094,150 @@ export class DocsDefinitionResolver { throw new CliError({ message: errorMessage, code: CliError.Code.ConfigError }); } + /** + * Builds a translated IR for each configured non-default locale whose + * `translations//apis//` directory contains a translated copy of + * the API's OpenAPI spec. The translated spec must be structurally identical to the + * base (same paths, operationIds, schema names) so the derived IR lines up 1:1 and + * can be keyed by the same `apiDefinitionId`. + */ + private async buildTranslatedApiIrs( + item: docsYml.DocsNavigationItem.ApiSection, + baseOssWorkspace: OSSWorkspace + ): Promise | undefined> { + const translationsConfig = this.getDocsTranslationsConfig(); + if (translationsConfig?.translations == null || translationsConfig.translations.length === 0) { + return undefined; + } + + const { defaultLocale, translations } = translationsConfig; + const fernFolder = this.docsWorkspace.absoluteFilePath; + const result = new Map(); + + for (const locale of translations) { + if (locale === defaultLocale) { + continue; + } + + const translatedWorkspace = this.createTranslatedOssWorkspace(baseOssWorkspace, locale, fernFolder); + if (translatedWorkspace == null) { + continue; + } + + try { + const translatedIr = await translatedWorkspace.getIntermediateRepresentation({ + context: this.taskContext, + audiences: item.audiences, + enableUniqueErrorsPerEndpoint: true, + generateV1Examples: false, + logWarnings: false + }); + result.set(locale, translatedIr); + this.taskContext.logger.debug( + `Built translated API definition for locale "${locale}" (api: ${ + item.apiName ?? baseOssWorkspace.workspaceName ?? "" + })` + ); + } catch (error) { + // A broken translated spec must never fail the docs build; fall back + // to the base (untranslated) API for this locale. + this.taskContext.logger.warn( + `Failed to build translated API definition for locale "${locale}": ${extractErrorMessage( + error + )}. Falling back to the default-locale API for this locale.` + ); + } + } + + return result.size > 0 ? result : undefined; + } + + /** + * Clones an OSS (OpenAPI) workspace, remapping spec file paths from `apis/<...>` to + * `translations//apis/<...>` wherever a translated file exists. Returns + * `undefined` when no primary spec has a translated counterpart. Override/overlay + * files fall back to the base copy when untranslated, keeping structural metadata + * (x-fern-* extensions, audiences, etc.) consistent with the base definition. + */ + private createTranslatedOssWorkspace( + base: OSSWorkspace, + locale: string, + fernFolder: AbsoluteFilePath + ): OSSWorkspace | undefined { + let hasTranslatedSpec = false; + + const toTranslatedPath = (absPath: AbsoluteFilePath, countsAsTranslation: boolean): AbsoluteFilePath => { + const rel = relative(fernFolder, absPath); + // Specs outside the fern folder (e.g. absolute/remote refs) have no + // translation directory equivalent — leave them untouched. + if (String(rel).startsWith("..")) { + return absPath; + } + const candidate = join(fernFolder, RelativeFilePath.of(`translations/${locale}`), rel); + if (existsSync(candidate)) { + if (countsAsTranslation) { + hasTranslatedSpec = true; + } + return candidate; + } + return absPath; + }; + + const remapOverrides = ( + overrides: AbsoluteFilePath | AbsoluteFilePath[] | undefined + ): AbsoluteFilePath | AbsoluteFilePath[] | undefined => { + if (overrides == null) { + return overrides; + } + if (Array.isArray(overrides)) { + return overrides.map((p) => toTranslatedPath(p, false)); + } + return toTranslatedPath(overrides, false); + }; + + const remapSpec = (spec: Spec): Spec => { + switch (spec.type) { + case "openapi": + return { + ...spec, + absoluteFilepath: toTranslatedPath(spec.absoluteFilepath, true), + absoluteFilepathToOverrides: remapOverrides(spec.absoluteFilepathToOverrides), + absoluteFilepathToOverlays: + spec.absoluteFilepathToOverlays != null + ? toTranslatedPath(spec.absoluteFilepathToOverlays, false) + : undefined + }; + case "openrpc": + case "graphql": + return { + ...spec, + absoluteFilepath: toTranslatedPath(spec.absoluteFilepath, true), + absoluteFilepathToOverrides: remapOverrides(spec.absoluteFilepathToOverrides) + }; + default: + // Protobuf specs are generated, not hand-translated — leave as-is. + return spec; + } + }; + + const translatedSpecs = base.specs.map((spec) => remapSpec(spec)) as OSSWorkspace.Args["specs"]; + const translatedAllSpecs = base.allSpecs.map((spec) => remapSpec(spec)); + + if (!hasTranslatedSpec) { + return undefined; + } + + return new OSSWorkspace({ + allSpecs: translatedAllSpecs, + specs: translatedSpecs, + generatorsConfiguration: base.generatorsConfiguration, + workspaceName: base.workspaceName, + cliVersion: base.cliVersion, + absoluteFilePath: base.absoluteFilePath, + changelog: base.changelog + }); + } + private async toRootNode(): Promise { const slug = FernNavigation.V1.SlugGenerator.init(FernNavigation.slugjoin(this.getDocsBasePath())); const id = this.#idgen.get("root"); @@ -1647,6 +1867,13 @@ export class DocsDefinitionResolver { const apiReferenceNode = node.get(); + // Only the v3 (OpenAPI) parser path supports translated API IRs, since it needs + // an OSS workspace whose spec file paths can be remapped to the translated dir. + let translatedIrsByLocale: Map | undefined; + if (this.buildTranslatedApiDefinitions && openapiWorkspace != null) { + translatedIrsByLocale = await this.buildTranslatedApiIrs(item, openapiWorkspace); + } + // Store pending registration for deferred processing after markdownFilesToPathName is available this.pendingApiRegistrations.push({ ir, @@ -1657,7 +1884,8 @@ export class DocsDefinitionResolver { graphqlOperations: graphqlData.operations, graphqlTypes: graphqlData.types, tempApiDefinitionId, - apiReferenceNode + apiReferenceNode, + translatedIrsByLocale }); return apiReferenceNode; diff --git a/packages/cli/docs-resolver/src/__test__/applyTranslatedApiTitlesToNavTree.test.ts b/packages/cli/docs-resolver/src/__test__/applyTranslatedApiTitlesToNavTree.test.ts new file mode 100644 index 000000000000..9bda609e1a78 --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/applyTranslatedApiTitlesToNavTree.test.ts @@ -0,0 +1,245 @@ +import { APIV1Read, FernNavigation } from "@fern-api/fdr-sdk"; +import { describe, expect, it } from "vitest"; + +import { applyTranslatedApiTitlesToNavTree } from "../applyTranslatedApiTitlesToNavTree.js"; + +const ROOT_PACKAGE_ID = "__package__"; + +// Casts a plain test fixture to RootNode. Mirrors the convention used by the +// sibling applyTranslatedFrontmatterToNavTree test — the helper only reads a +// handful of fields, so we avoid constructing fully-typed nav trees. +function asRoot(obj: unknown): FernNavigation.V1.RootNode { + return obj as FernNavigation.V1.RootNode; +} + +// Builds a minimal APIV1Read.ApiDefinition. The package shape matches what +// ApiDefinitionHolder reads (rootPackage + subpackages, each with endpoints / +// websockets / webhooks arrays). Subpackages are keyed by subpackageId and carry +// a `subpackageId` field so isSubpackage() detects them. +function makeApi(args: { + rootEndpoints?: Array>; + rootWebhooks?: Array>; + rootWebSockets?: Array>; + subpackages?: Record< + string, + { + name: string; + displayName?: string; + endpoints?: Array>; + webhooks?: Array>; + webSockets?: Array>; + } + >; +}): APIV1Read.ApiDefinition { + const subpackages = Object.fromEntries( + Object.entries(args.subpackages ?? {}).map(([subpackageId, sub]) => [ + subpackageId, + { + subpackageId, + name: sub.name, + displayName: sub.displayName, + endpoints: sub.endpoints ?? [], + websockets: sub.webSockets ?? [], + webhooks: sub.webhooks ?? [] + } + ]) + ); + return { + rootPackage: { + endpoints: args.rootEndpoints ?? [], + websockets: args.rootWebSockets ?? [], + webhooks: args.rootWebhooks ?? [] + }, + subpackages, + types: {} + } as unknown as APIV1Read.ApiDefinition; +} + +const API_ID = "api-1"; + +describe("applyTranslatedApiTitlesToNavTree", () => { + it("translates a root-package endpoint title matched by originalEndpointId", () => { + const baseApis = { + [API_ID]: makeApi({ rootEndpoints: [{ id: "list", name: "List accounts", originalEndpointId: "ep.list" }] }) + }; + const translatedApis = { + [API_ID]: makeApi({ + rootEndpoints: [{ id: "list", name: "アカウント一覧", originalEndpointId: "ep.list" }] + }) + }; + const root = { + type: "root", + child: { + type: "endpoint", + endpointId: "ep.list", + apiDefinitionId: API_ID, + title: "List accounts" + } + }; + + const result = applyTranslatedApiTitlesToNavTree(asRoot(root), baseApis, translatedApis); + const child = (result as unknown as { child: { title: string } }).child; + expect(child.title).toBe("アカウント一覧"); + }); + + it("translates an endpoint nested in a subpackage (id derived from subpackageId.id)", () => { + const baseApis = { + [API_ID]: makeApi({ + subpackages: { comments: { name: "comments", endpoints: [{ id: "create", name: "Create comment" }] } } + }) + }; + const translatedApis = { + [API_ID]: makeApi({ + subpackages: { comments: { name: "comments", endpoints: [{ id: "create", name: "コメントを作成" }] } } + }) + }; + const root = { + type: "root", + child: { + type: "endpoint", + endpointId: "comments.create", + apiDefinitionId: API_ID, + title: "Create comment" + } + }; + + const result = applyTranslatedApiTitlesToNavTree(asRoot(root), baseApis, translatedApis); + const child = (result as unknown as { child: { title: string } }).child; + expect(child.title).toBe("コメントを作成"); + }); + + it("translates webhook titles", () => { + const baseApis = { + [API_ID]: makeApi({ rootWebhooks: [{ id: "on-event", name: "On event" }] }) + }; + const translatedApis = { + [API_ID]: makeApi({ rootWebhooks: [{ id: "on-event", name: "イベント発生時" }] }) + }; + const root = { + type: "root", + child: { + type: "webhook", + webhookId: `${ROOT_PACKAGE_ID}.on-event`, + apiDefinitionId: API_ID, + title: "On event" + } + }; + + const result = applyTranslatedApiTitlesToNavTree(asRoot(root), baseApis, translatedApis); + const child = (result as unknown as { child: { title: string } }).child; + expect(child.title).toBe("イベント発生時"); + }); + + it("translates apiPackage (subpackage) group titles matched by base title", () => { + const baseApis = { + [API_ID]: makeApi({ subpackages: { comments: { name: "comments", displayName: "Comments" } } }) + }; + const translatedApis = { + [API_ID]: makeApi({ subpackages: { comments: { name: "comments", displayName: "コメント" } } }) + }; + const root = { + type: "root", + child: { + type: "apiPackage", + apiDefinitionId: API_ID, + title: "Comments", + children: [] + } + }; + + const result = applyTranslatedApiTitlesToNavTree(asRoot(root), baseApis, translatedApis); + const child = (result as unknown as { child: { title: string } }).child; + expect(child.title).toBe("コメント"); + }); + + it("does not mutate the input root (returns a deep clone)", () => { + const baseApis = { + [API_ID]: makeApi({ rootEndpoints: [{ id: "list", name: "List", originalEndpointId: "ep.list" }] }) + }; + const translatedApis = { + [API_ID]: makeApi({ rootEndpoints: [{ id: "list", name: "一覧", originalEndpointId: "ep.list" }] }) + }; + const root = { + type: "root", + child: { type: "endpoint", endpointId: "ep.list", apiDefinitionId: API_ID, title: "List" } + }; + + const result = applyTranslatedApiTitlesToNavTree(asRoot(root), baseApis, translatedApis); + + expect(root.child.title).toBe("List"); + expect((result as unknown as { child: { title: string } }).child.title).toBe("一覧"); + expect(result).not.toBe(root); + }); + + it("leaves the title unchanged when the translated endpoint has no name", () => { + const baseApis = { + [API_ID]: makeApi({ rootEndpoints: [{ id: "list", name: "List", originalEndpointId: "ep.list" }] }) + }; + const translatedApis = { + [API_ID]: makeApi({ rootEndpoints: [{ id: "list", name: "", originalEndpointId: "ep.list" }] }) + }; + const root = { + type: "root", + child: { type: "endpoint", endpointId: "ep.list", apiDefinitionId: API_ID, title: "List" } + }; + + const result = applyTranslatedApiTitlesToNavTree(asRoot(root), baseApis, translatedApis); + const child = (result as unknown as { child: { title: string } }).child; + expect(child.title).toBe("List"); + }); + + it("ignores nodes whose apiDefinitionId does not match any translated API", () => { + const baseApis = { + [API_ID]: makeApi({ rootEndpoints: [{ id: "list", name: "List", originalEndpointId: "ep.list" }] }) + }; + const translatedApis = { + [API_ID]: makeApi({ rootEndpoints: [{ id: "list", name: "一覧", originalEndpointId: "ep.list" }] }) + }; + const root = { + type: "root", + child: { + type: "endpoint", + endpointId: "ep.list", + apiDefinitionId: "some-other-api", + title: "List" + } + }; + + const result = applyTranslatedApiTitlesToNavTree(asRoot(root), baseApis, translatedApis); + const child = (result as unknown as { child: { title: string } }).child; + expect(child.title).toBe("List"); + }); + + it("translates titles across multiple APIs in the same tree", () => { + const API_2 = "api-2"; + const baseApis = { + [API_ID]: makeApi({ rootEndpoints: [{ id: "a", name: "Alpha", originalEndpointId: "a.id" }] }), + [API_2]: makeApi({ rootEndpoints: [{ id: "b", name: "Beta", originalEndpointId: "b.id" }] }) + }; + const translatedApis = { + [API_ID]: makeApi({ rootEndpoints: [{ id: "a", name: "アルファ", originalEndpointId: "a.id" }] }), + [API_2]: makeApi({ rootEndpoints: [{ id: "b", name: "ベータ", originalEndpointId: "b.id" }] }) + }; + const root = { + type: "root", + children: [ + { type: "endpoint", endpointId: "a.id", apiDefinitionId: API_ID, title: "Alpha" }, + { type: "endpoint", endpointId: "b.id", apiDefinitionId: API_2, title: "Beta" } + ] + }; + + const result = applyTranslatedApiTitlesToNavTree(asRoot(root), baseApis, translatedApis); + const children = (result as unknown as { children: Array<{ title: string }> }).children; + expect(children[0]?.title).toBe("アルファ"); + expect(children[1]?.title).toBe("ベータ"); + }); + + it("returns an equivalent tree when there are no translated APIs", () => { + const root = { + type: "root", + child: { type: "endpoint", endpointId: "ep.list", apiDefinitionId: API_ID, title: "List" } + }; + const result = applyTranslatedApiTitlesToNavTree(asRoot(root), {}, {}); + expect(result).toEqual(root); + }); +}); diff --git a/packages/cli/docs-resolver/src/applyTranslatedApiTitlesToNavTree.ts b/packages/cli/docs-resolver/src/applyTranslatedApiTitlesToNavTree.ts new file mode 100644 index 000000000000..21a2e5806344 --- /dev/null +++ b/packages/cli/docs-resolver/src/applyTranslatedApiTitlesToNavTree.ts @@ -0,0 +1,137 @@ +import { titleCase } from "@fern-api/core-utils"; +import { APIV1Read, FernNavigation } from "@fern-api/fdr-sdk"; + +interface ApiTitleMaps { + endpoints: Map; + webhooks: Map; + webSockets: Map; + graphql: Map; + /** Subpackage grouping titles, keyed by the default-locale (base) computed title. */ + packagesByBaseTitle: Map; +} + +/** + * Returns a deep clone of the navigation tree with API-reference node titles + * (endpoints, webhooks, websockets, subpackages) replaced by their translated + * equivalents, so the left-hand sidebar isn't left in the default language when a + * translated API definition is paired with the base navigation tree. + * + * Endpoint-like nodes are matched by the nav ids the tree was built from (via + * {@link FernNavigation.ApiDefinitionHolder}). Subpackage grouping nodes don't + * persist their subpackage id, so they are matched by their base-locale title, + * which is unique within a single API. + * + * @param root - the navigation tree whose API titles should be localized + * @param baseApis - default-locale API definitions, keyed by apiDefinitionId + * (used to derive subpackage base titles) + * @param translatedApis - translated API definitions, keyed by the same apiDefinitionId + */ +export function applyTranslatedApiTitlesToNavTree( + root: FernNavigation.V1.RootNode, + baseApis: Record, + translatedApis: Record +): FernNavigation.V1.RootNode { + const mapsByApiId = new Map(); + for (const [apiId, translatedApi] of Object.entries(translatedApis)) { + const holder = FernNavigation.ApiDefinitionHolder.create(translatedApi); + + const endpoints = new Map(); + for (const [id, endpoint] of holder.endpoints) { + if (endpoint.name != null && endpoint.name.length > 0) { + endpoints.set(id, endpoint.name); + } + } + const webhooks = new Map(); + for (const [id, webhook] of holder.webhooks) { + if (webhook.name != null && webhook.name.length > 0) { + webhooks.set(id, webhook.name); + } + } + const webSockets = new Map(); + for (const [id, webSocket] of holder.webSockets) { + if (webSocket.name != null && webSocket.name.length > 0) { + webSockets.set(id, webSocket.name); + } + } + const graphql = new Map(); + for (const [id, operation] of holder.graphqlOperations) { + const name = operation.displayName ?? operation.name; + if (name != null && name.length > 0) { + graphql.set(id, name); + } + } + + // Matched by base-locale title; only record entries whose title actually + // changes, so untranslated subpackages are left alone. + const packagesByBaseTitle = new Map(); + const baseApi = baseApis[apiId]; + if (baseApi != null) { + for (const [subpackageId, translatedSubpackage] of Object.entries(translatedApi.subpackages)) { + const baseSubpackage = baseApi.subpackages[subpackageId]; + if (baseSubpackage == null) { + continue; + } + const baseTitle = baseSubpackage.displayName ?? titleCase(baseSubpackage.name); + const translatedTitle = translatedSubpackage.displayName ?? titleCase(translatedSubpackage.name); + if (baseTitle !== translatedTitle) { + packagesByBaseTitle.set(baseTitle, translatedTitle); + } + } + } + + mapsByApiId.set(apiId, { endpoints, webhooks, webSockets, graphql, packagesByBaseTitle }); + } + + // Deep clone so we never mutate the base nav tree, which may be shared by reference. + const clone = structuredClone(root); + + const visit = (node: unknown): void => { + if (node == null || typeof node !== "object") { + return; + } + if (Array.isArray(node)) { + for (const item of node) { + visit(item); + } + return; + } + const obj = node as Record; + const apiDefinitionId = typeof obj.apiDefinitionId === "string" ? obj.apiDefinitionId : undefined; + const maps = apiDefinitionId != null ? mapsByApiId.get(apiDefinitionId) : undefined; + if (maps != null) { + const type = obj.type; + if (type === "endpoint" && typeof obj.endpointId === "string") { + const translated = maps.endpoints.get(obj.endpointId); + if (translated != null) { + obj.title = translated; + } + } else if (type === "webhook" && typeof obj.webhookId === "string") { + const translated = maps.webhooks.get(obj.webhookId); + if (translated != null) { + obj.title = translated; + } + } else if (type === "webSocket" && typeof obj.webSocketId === "string") { + const translated = maps.webSockets.get(obj.webSocketId); + if (translated != null) { + obj.title = translated; + } + } else if (type === "graphql" && typeof obj.graphqlOperationId === "string") { + const translated = maps.graphql.get(obj.graphqlOperationId); + if (translated != null) { + obj.title = translated; + } + } else if (type === "apiPackage" && typeof obj.title === "string") { + const translated = maps.packagesByBaseTitle.get(obj.title); + if (translated != null) { + obj.title = translated; + } + } + } + for (const value of Object.values(obj)) { + visit(value); + } + }; + + visit(clone); + return clone; +} diff --git a/packages/cli/docs-resolver/src/index.ts b/packages/cli/docs-resolver/src/index.ts index 86fb39895525..7820e329e85c 100644 --- a/packages/cli/docs-resolver/src/index.ts +++ b/packages/cli/docs-resolver/src/index.ts @@ -8,15 +8,22 @@ export { stripMdxComments, transformAtPrefixImports } from "@fern-api/docs-markdown-utils"; +export { applyTranslatedApiTitlesToNavTree } from "./applyTranslatedApiTitlesToNavTree.js"; export { applyTranslatedFrontmatterToNavTree } from "./applyTranslatedFrontmatterToNavTree.js"; export { applyTranslatedNavigationOverlays, getTranslatedAnnouncement } from "./applyTranslatedNavigationOverlays.js"; export type TranslationNavigationOverlay = docsYml.TranslationNavigationOverlay; -export { DocsDefinitionResolver, type UploadedFile } from "./DocsDefinitionResolver.js"; +export { + DocsDefinitionResolver, + type RegisterApiFn, + type TranslatedApiSpec, + type UploadedFile +} from "./DocsDefinitionResolver.js"; export { stitchGlobalTheme } from "./stitchGlobalTheme.js"; export { convertIrToApiDefinition } from "./utils/convertIrToApiDefinition.js"; export { filterOssWorkspaces } from "./utils/filterOssWorkspaces.js"; export { generateFdrFromOpenApiWorkspaceV3 } from "./utils/generateFdrFromOpenAPIWorkspaceV3.js"; +export { updateApiDefinitionIdInTree } from "./utils/resolveDescriptionLinks.js"; export { wrapWithHttps } from "./wrapWithHttps.js"; diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts index f30f999d0939..52395e14ba94 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts @@ -4,19 +4,33 @@ import { docsYml, generatorsYml } from "@fern-api/configuration"; import { createFdrService } from "@fern-api/core"; import { MediaType, replaceEnvVariables } from "@fern-api/core-utils"; import { + applyTranslatedApiTitlesToNavTree, applyTranslatedFrontmatterToNavTree, applyTranslatedNavigationOverlays, DocsDefinitionResolver, getTranslatedAnnouncement, + type RegisterApiFn, replaceImagePathsAndUrls, replaceReferencedCode, replaceReferencedMarkdown, stripMdxComments, + type TranslatedApiSpec, transformAtPrefixImports, UploadedFile, + updateApiDefinitionIdInTree, wrapWithHttps } from "@fern-api/docs-resolver"; -import { APIV1Write, FdrAPI as CjsFdrSdk, DocsV1Write, DocsV2Write, FdrClient } from "@fern-api/fdr-sdk"; +import { + APIV1Read, + APIV1Write, + FdrAPI as CjsFdrSdk, + convertAPIDefinitionToDb, + convertDbAPIDefinitionToRead, + DocsV1Write, + DocsV2Write, + FdrClient, + SDKSnippetHolder +} from "@fern-api/fdr-sdk"; type DynamicIr = APIV1Write.DynamicIr; type DynamicIRUpload = APIV1Write.DynamicIRUpload; @@ -261,6 +275,180 @@ export async function publishDocs({ taskContext: context }); + // Translated API definitions are registered to FDR after the base definition + // resolves; the per-locale nav tree is then repointed at them (see below), which + // is all FDR needs to embed localized API reference content on published sites. + const buildTranslatedApiDefinitions = + docsWorkspace.config.translations != null && docsWorkspace.config.translations.length > 1; + + // Read-form API definitions captured during registration, keyed by FDR + // apiDefinitionId, used to localize sidebar (navigation) titles. Only populated + // when translations are configured, to avoid extra conversion on normal publishes. + const readApiDefinitionsById = new Map(); + const captureReadApiDefinition = (definition: APIV1Write.ApiDefinition, apiDefinitionId: string): void => { + if (!buildTranslatedApiDefinitions) { + return; + } + try { + const dbApiDefinition = convertAPIDefinitionToDb( + definition, + CjsFdrSdk.ApiDefinitionId(apiDefinitionId), + new SDKSnippetHolder({ + snippetsConfigWithSdkId: {}, + snippetsBySdkId: {}, + snippetTemplatesByEndpoint: {}, + snippetTemplatesByEndpointId: {}, + snippetsBySdkIdAndEndpointId: {} + }) + ); + readApiDefinitionsById.set(apiDefinitionId, convertDbAPIDefinitionToRead(dbApiDefinition)); + } catch (error) { + context.logger.debug( + `Failed to build read API definition for ${apiDefinitionId} (translated sidebar titles may stay in the default language): ${String(error)}` + ); + } + }; + + /** + * Registers an API definition with FDR (with AI example enhancement and dynamic + * snippet generation) and returns the resulting apiDefinitionId. Used for the base + * definition (via the resolver) and, after resolve, for each locale's translated + * definition. + */ + const registerApiToFdr: RegisterApiFn = async ({ + ir, + snippetsConfig, + playgroundConfig, + apiName, + workspace, + graphqlOperations, + graphqlTypes + }) => { + // apiName (docs.yml folder name) becomes the FDR API identifier, so users can + // reference APIs by their folder name in docs components. + let apiDefinition = convertIrToFdrApi({ + ir, + snippetsConfig, + playgroundConfig, + graphqlOperations, + graphqlTypes, + context, + apiNameOverride: apiName + }); + + const isSelfHosted = token.value === "dummy"; + const aiEnhancerConfig = getAIEnhancerConfig( + withAiExamples && !isSelfHosted, + docsWorkspace.config.aiExamples?.style ?? docsWorkspace.config.experimental?.aiExampleStyleInstructions + ); + if (aiEnhancerConfig) { + const sources = workspace?.getSources(); + const openApiSources = sources + ?.filter((source) => source.type === "openapi") + .map((source) => ({ + absoluteFilePath: source.absoluteFilePath, + absoluteFilePathToOverrides: source.absoluteFilePathToOverrides + })); + + if (openApiSources == null || openApiSources.length === 0) { + context.logger.debug("Skipping AI example enhancement: no OpenAPI source file paths available"); + } else { + apiDefinition = await enhanceExamplesWithAI( + apiDefinition, + aiEnhancerConfig, + context, + token, + organization, + openApiSources + ); + } + } + + // create dynamic IR + metadata for each generator language + let dynamicIRsByLanguage: Record | undefined; + let languagesWithExistingSdkDynamicIr: Set = new Set(); + if (Object.keys(snippetsConfig).length === 0) { + context.logger.debug(`No snippets configuration defined, skipping snippet generation...`); + } else if (!disableDynamicSnippets) { + const existingSdkDynamicIrs = await checkAndDownloadExistingSdkDynamicIRs({ + fdr, + workspace, + organization, + context, + snippetsConfig + }); + + if (existingSdkDynamicIrs && Object.keys(existingSdkDynamicIrs).length > 0) { + dynamicIRsByLanguage = existingSdkDynamicIrs; + languagesWithExistingSdkDynamicIr = new Set(Object.keys(existingSdkDynamicIrs)); + context.logger.debug( + `Using existing SDK dynamic IRs for: ${Object.keys(existingSdkDynamicIrs).join(", ")}` + ); + } + + const generatedDynamicIRs = await generateLanguageSpecificDynamicIRs({ + workspace, + organization, + context, + snippetsConfig, + skipLanguages: languagesWithExistingSdkDynamicIr + }); + + if (generatedDynamicIRs) { + dynamicIRsByLanguage = { + ...dynamicIRsByLanguage, + ...generatedDynamicIRs + }; + } + } + + let response; + try { + response = await fdr.api.register.registerApiDefinition({ + orgId: CjsFdrSdk.OrgId(organization), + apiId: CjsFdrSdk.ApiId(apiName ?? getOriginalName(ir.apiName)), + definition: apiDefinition, + dynamicIRs: dynamicIRsByLanguage + }); + } catch (error) { + const errorDetails = extractErrorDetails(error); + context.logger.error( + `FDR registerApiDefinition failed. Error details:\n${JSON.stringify(errorDetails, undefined, 2)}` + ); + if (apiName != null) { + return context.failAndThrow( + `Failed to publish docs because API definition (${apiName}) could not be uploaded. Please contact support@buildwithfern.com`, + errorDetails, + { code: CliError.Code.NetworkError } + ); + } else { + return context.failAndThrow( + `Failed to publish docs because API definition could not be uploaded. Please contact support@buildwithfern.com`, + errorDetails, + { code: CliError.Code.NetworkError } + ); + } + } + + context.logger.debug(`Registered API Definition ${apiName}: ${response.apiDefinitionId}`); + + if (response.dynamicIRs && dynamicIRsByLanguage) { + if (skipUpload) { + context.logger.debug("Skip-upload mode: skipping dynamic IR uploads"); + } else { + await uploadDynamicIRs({ + dynamicIRs: dynamicIRsByLanguage, + dynamicIRUploadUrls: response.dynamicIRs, + context, + apiId: response.apiDefinitionId + }); + } + } + + captureReadApiDefinition(apiDefinition, response.apiDefinitionId); + return response.apiDefinitionId; + }; + const resolver = new DocsDefinitionResolver({ domain, docsWorkspace: effectiveWorkspace, @@ -461,141 +649,8 @@ export async function publishDocs({ ); } }, - registerApi: async ({ - ir, - snippetsConfig, - playgroundConfig, - apiName, - workspace, - graphqlOperations, - graphqlTypes - }) => { - // Use apiName from docs.yml (folder name) as the API identifier for FDR - // This ensures users can reference APIs by their folder name in docs components - let apiDefinition = convertIrToFdrApi({ - ir, - snippetsConfig, - playgroundConfig, - graphqlOperations, - graphqlTypes, - context, - apiNameOverride: apiName - }); - - const isSelfHosted = token.value === "dummy"; - const aiEnhancerConfig = getAIEnhancerConfig( - withAiExamples && !isSelfHosted, - docsWorkspace.config.aiExamples?.style ?? - docsWorkspace.config.experimental?.aiExampleStyleInstructions - ); - if (aiEnhancerConfig) { - const sources = workspace?.getSources(); - const openApiSources = sources - ?.filter((source) => source.type === "openapi") - .map((source) => ({ - absoluteFilePath: source.absoluteFilePath, - absoluteFilePathToOverrides: source.absoluteFilePathToOverrides - })); - - if (openApiSources == null || openApiSources.length === 0) { - context.logger.debug("Skipping AI example enhancement: no OpenAPI source file paths available"); - } else { - apiDefinition = await enhanceExamplesWithAI( - apiDefinition, - aiEnhancerConfig, - context, - token, - organization, - openApiSources - ); - } - } - - // create dynamic IR + metadata for each generator language - let dynamicIRsByLanguage: Record | undefined; - let languagesWithExistingSdkDynamicIr: Set = new Set(); - if (Object.keys(snippetsConfig).length === 0) { - context.logger.debug(`No snippets configuration defined, skipping snippet generation...`); - } else if (!disableDynamicSnippets) { - // Check for existing SDK dynamic IRs before generating - const existingSdkDynamicIrs = await checkAndDownloadExistingSdkDynamicIRs({ - fdr, - workspace, - organization, - context, - snippetsConfig - }); - - if (existingSdkDynamicIrs && Object.keys(existingSdkDynamicIrs).length > 0) { - dynamicIRsByLanguage = existingSdkDynamicIrs; - languagesWithExistingSdkDynamicIr = new Set(Object.keys(existingSdkDynamicIrs)); - context.logger.debug( - `Using existing SDK dynamic IRs for: ${Object.keys(existingSdkDynamicIrs).join(", ")}` - ); - } - - // Generate dynamic IRs for languages that don't have existing SDK dynamic IRs - const generatedDynamicIRs = await generateLanguageSpecificDynamicIRs({ - workspace, - organization, - context, - snippetsConfig, - skipLanguages: languagesWithExistingSdkDynamicIr - }); - - if (generatedDynamicIRs) { - dynamicIRsByLanguage = { - ...dynamicIRsByLanguage, - ...generatedDynamicIRs - }; - } - } - - let response; - try { - response = await fdr.api.register.registerApiDefinition({ - orgId: CjsFdrSdk.OrgId(organization), - apiId: CjsFdrSdk.ApiId(apiName ?? getOriginalName(ir.apiName)), - definition: apiDefinition, - dynamicIRs: dynamicIRsByLanguage - }); - } catch (error) { - const errorDetails = extractErrorDetails(error); - context.logger.error( - `FDR registerApiDefinition failed. Error details:\n${JSON.stringify(errorDetails, undefined, 2)}` - ); - if (apiName != null) { - return context.failAndThrow( - `Failed to publish docs because API definition (${apiName}) could not be uploaded. Please contact support@buildwithfern.com`, - errorDetails, - { code: CliError.Code.NetworkError } - ); - } else { - return context.failAndThrow( - `Failed to publish docs because API definition could not be uploaded. Please contact support@buildwithfern.com`, - errorDetails, - { code: CliError.Code.NetworkError } - ); - } - } - - context.logger.debug(`Registered API Definition ${apiName}: ${response.apiDefinitionId}`); - - if (response.dynamicIRs && dynamicIRsByLanguage) { - if (skipUpload) { - context.logger.debug("Skip-upload mode: skipping dynamic IR uploads"); - } else { - await uploadDynamicIRs({ - dynamicIRs: dynamicIRsByLanguage, - dynamicIRUploadUrls: response.dynamicIRs, - context, - apiId: response.apiDefinitionId - }); - } - } - - return response.apiDefinitionId; - }, + registerApi: registerApiToFdr, + buildTranslatedApiDefinitions, targetAudiences }); @@ -651,6 +706,40 @@ export async function publishDocs({ const publishTime = performance.now() - publishStart; context.logger.debug(`Docs published to FDR in ${publishTime.toFixed(0)}ms`); + // Register the translated API definitions for each locale. FDR keys API content + // by apiDefinitionId, so each translated definition gets its own content-addressed + // id; we map base -> translated ids per locale and rewrite the nav tree below. + const translatedApiSpecsByLocale: Map> = buildTranslatedApiDefinitions + ? resolver.getTranslatedApiSpecs() + : new Map(); + const translatedApiIdsByLocale = new Map>(); + if (translatedApiSpecsByLocale.size > 0) { + context.logger.info( + `Registering translated API definitions for ${translatedApiSpecsByLocale.size} locale(s)...` + ); + for (const [locale, specsByBaseApiId] of translatedApiSpecsByLocale) { + const idMap = new Map(); + for (const [baseApiId, spec] of specsByBaseApiId) { + try { + const translatedApiId = await registerApiToFdr(spec); + // A content-addressed id identical to the base means this + // locale has no API translations; leave the nav untouched. + if (translatedApiId !== baseApiId) { + idMap.set(baseApiId, translatedApiId); + } + } catch (error) { + context.logger.warn( + `Failed to register translated API definition for locale "${locale}" ` + + `(API reference will render in the default language): ${String(error)}` + ); + } + } + if (idMap.size > 0) { + translatedApiIdsByLocale.set(locale, idMap); + } + } + } + // Register translated page content for each configured locale. // In preview mode, register translations against the preview URL (not the production domain) // so that translated docs are visible in preview without overwriting production translations. @@ -806,6 +895,41 @@ export async function publishDocs({ } } + // Localize API reference content for this locale: patch the sidebar + // titles (while the nav still references the base apiDefinitionId), + // then repoint the nav's apiDefinitionId references at the translated + // definitions registered above. + const localeApiIdMap = translatedApiIdsByLocale.get(locale); + if (localeApiIdMap != null && localeApiIdMap.size > 0 && updatedRoot != null) { + const baseApisForTitles: Record = {}; + const translatedApisForTitles: Record = {}; + for (const [baseApiId, translatedApiId] of localeApiIdMap) { + const baseRead = readApiDefinitionsById.get(baseApiId); + const translatedRead = readApiDefinitionsById.get(translatedApiId); + if (baseRead != null) { + baseApisForTitles[baseApiId] = baseRead; + } + if (translatedRead != null) { + // Key by the base id so titles match the (still base-keyed) nav tree. + translatedApisForTitles[baseApiId] = translatedRead; + } + } + // Work on a deep clone before the in-place id rewrite below, since + // locales run concurrently off the shared base nav tree. Title + // patching already clones, so only clone explicitly when it's skipped. + updatedRoot = + Object.keys(translatedApisForTitles).length > 0 + ? applyTranslatedApiTitlesToNavTree( + updatedRoot, + baseApisForTitles, + translatedApisForTitles + ) + : structuredClone(updatedRoot); + for (const [baseApiId, translatedApiId] of localeApiIdMap) { + updateApiDefinitionIdInTree(updatedRoot, baseApiId, translatedApiId); + } + } + const translatedDefinition: DocsDefinition = { ...docsDefinition, pages: translatedPages, From 5aa7bbe9f3f19b1db116c6ecfd8dc5f41742f50f Mon Sep 17 00:00:00 2001 From: jsklan <100491078+jsklan@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:52:58 -0400 Subject: [PATCH 12/14] fix(cli-generator): remove accidental _cli-sdk submodule gitlink (#16203) PR #16187 (sync cli-sdk@9c1f38f) accidentally committed `_cli-sdk` as a submodule gitlink (mode 160000) with no `.gitmodules` backing it. The sync workflow checks out fern-api/cli-sdk into `_cli-sdk` at the repo root, and `peter-evans/create-pull-request` runs `git add -A` across the whole tree, capturing that nested clone as a phantom submodule. This breaks clean checkouts and `git submodule status` in downstream CI (Update Seed, etc.). - Remove the stray `_cli-sdk` gitlink from the tree. - Add `/_cli-sdk/` to .gitignore so no `git add -A` can capture it again. - Scope the sync workflow's create-pull-request step to `add-paths: generators/cli/sdk/` so it only stages the synced path. --- .github/workflows/sync-cli-sdk.yml | 4 ++++ .gitignore | 4 ++++ _cli-sdk | 1 - 3 files changed, 8 insertions(+), 1 deletion(-) delete mode 160000 _cli-sdk diff --git a/.github/workflows/sync-cli-sdk.yml b/.github/workflows/sync-cli-sdk.yml index 4db780421b49..3959092ef0f2 100644 --- a/.github/workflows/sync-cli-sdk.yml +++ b/.github/workflows/sync-cli-sdk.yml @@ -68,6 +68,10 @@ jobs: uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8 with: token: ${{ secrets.FERN_SUPPORT_GH_ACTIONS_PAT }} + # Scope staging to the synced path only. Without this the action runs + # `git add -A` across the whole tree and captures the transient + # `_cli-sdk` clone as a submodule gitlink (mode 160000). + add-paths: generators/cli/sdk/ commit-message: "chore(cli-generator): sync cli-sdk@${{ steps.provenance.outputs.short }}" title: "chore(cli-generator): sync cli-sdk@${{ steps.provenance.outputs.short }}" body: | diff --git a/.gitignore b/.gitignore index 178f83bda16a..cfbf376a0158 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ seed/**/Library/Caches/ # Devbox gradle properties /gradle.properties + +# Transient cli-sdk checkout used by .github/workflows/sync-cli-sdk.yml. +# It is a nested git clone; never let `git add -A` capture it as a gitlink. +/_cli-sdk/ diff --git a/_cli-sdk b/_cli-sdk deleted file mode 160000 index 9c1f38fd0ffb..000000000000 --- a/_cli-sdk +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9c1f38fd0ffba8da21db241c07179f48fa0cecf7 From cedcbae76ee95dbd75407c26b3ffd84eb99d4d7d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Jun 2026 23:43:46 +0000 Subject: [PATCH 13/14] chore(cli): release 5.44.4 --- .../fix-allof-nested-ref-resolution.yml | 0 ...iminated-union-base-properties-overlap.yml | 0 .../fix-translated-api-references.yml | 0 packages/cli/cli/versions.yml | 23 +++++++++++++++++++ 4 files changed, 23 insertions(+) rename packages/cli/cli/changes/{unreleased => 5.44.4}/fix-allof-nested-ref-resolution.yml (100%) rename packages/cli/cli/changes/{unreleased => 5.44.4}/fix-infer-discriminated-union-base-properties-overlap.yml (100%) rename packages/cli/cli/changes/{unreleased => 5.44.4}/fix-translated-api-references.yml (100%) diff --git a/packages/cli/cli/changes/unreleased/fix-allof-nested-ref-resolution.yml b/packages/cli/cli/changes/5.44.4/fix-allof-nested-ref-resolution.yml similarity index 100% rename from packages/cli/cli/changes/unreleased/fix-allof-nested-ref-resolution.yml rename to packages/cli/cli/changes/5.44.4/fix-allof-nested-ref-resolution.yml diff --git a/packages/cli/cli/changes/unreleased/fix-infer-discriminated-union-base-properties-overlap.yml b/packages/cli/cli/changes/5.44.4/fix-infer-discriminated-union-base-properties-overlap.yml similarity index 100% rename from packages/cli/cli/changes/unreleased/fix-infer-discriminated-union-base-properties-overlap.yml rename to packages/cli/cli/changes/5.44.4/fix-infer-discriminated-union-base-properties-overlap.yml diff --git a/packages/cli/cli/changes/unreleased/fix-translated-api-references.yml b/packages/cli/cli/changes/5.44.4/fix-translated-api-references.yml similarity index 100% rename from packages/cli/cli/changes/unreleased/fix-translated-api-references.yml rename to packages/cli/cli/changes/5.44.4/fix-translated-api-references.yml diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index d89d894e907e..4264674e91d8 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,4 +1,27 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 5.44.4 + changelogEntry: + - summary: | + Fix docs rendering dropping parent properties when an allOf parent schema + itself uses allOf with $ref elements. The v3 OpenAPI parser now recursively + resolves nested $ref objects during allOf flattening instead of silently + filtering them out. + type: fix + - summary: | + Fix OpenAPI parser emitting redundant or wrongly-required base properties for + discriminated unions when `infer-discriminated-union-base-properties: true`. + The inference now (1) skips properties every variant already inherits via a + shared `allOf $ref` parent — the parent schema is the single source of truth — + and (2) lifts an inferred property as `optional` if any variant declares it as + not-required. Together these prevent generated TypeScript SDKs from synthesizing + a `_Base` interface that collides with the real parent on either presence or + optionality (`TS2320`). + type: fix + - summary: | + Fix localized docs builds so translated OpenAPI specs are used for API reference content and navigation titles. + type: fix + createdAt: "2026-06-02" + irVersion: 66 - version: 5.44.3 changelogEntry: - summary: | From e73ed367b1e085f0f5aec19400b1faab47ff4865 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Jun 2026 23:44:16 +0000 Subject: [PATCH 14/14] chore(cli-generator): release 0.6.0 --- .../strip-fixture-tests.yml | 0 .../sync-cli-sdk-9c1f38f.yml | 0 generators/cli/versions.yml | 50 +++++++++++++++++++ 3 files changed, 50 insertions(+) rename generators/cli/changes/{unreleased => 0.6.0}/strip-fixture-tests.yml (100%) rename generators/cli/changes/{unreleased => 0.6.0}/sync-cli-sdk-9c1f38f.yml (100%) diff --git a/generators/cli/changes/unreleased/strip-fixture-tests.yml b/generators/cli/changes/0.6.0/strip-fixture-tests.yml similarity index 100% rename from generators/cli/changes/unreleased/strip-fixture-tests.yml rename to generators/cli/changes/0.6.0/strip-fixture-tests.yml diff --git a/generators/cli/changes/unreleased/sync-cli-sdk-9c1f38f.yml b/generators/cli/changes/0.6.0/sync-cli-sdk-9c1f38f.yml similarity index 100% rename from generators/cli/changes/unreleased/sync-cli-sdk-9c1f38f.yml rename to generators/cli/changes/0.6.0/sync-cli-sdk-9c1f38f.yml diff --git a/generators/cli/versions.yml b/generators/cli/versions.yml index 02da5917d0a2..2470126f7866 100644 --- a/generators/cli/versions.yml +++ b/generators/cli/versions.yml @@ -1,4 +1,54 @@ # yaml-language-server: $schema=../../fern-versions-yml.schema.json +- version: 0.6.0 + changelogEntry: + - summary: Strip fixture-dependent inline tests from vendored cli-sdk so `cargo test` passes in both the SDK and generated + CLIs + type: fix + - summary: | + Generated CLIs now serialize OpenAPI query parameters according to their + declared `style`/`explode` (form, spaceDelimited, pipeDelimited, deepObject). + (cli-sdk #144, FER-10569) + type: feat + - summary: | + Generated CLIs now serialize OpenAPI header parameters using the `simple` + style. (cli-sdk #137, FER-10569) + type: feat + - summary: | + Generated CLIs now serialize OpenAPI path parameters using the `simple`, + `label`, and `matrix` styles. (cli-sdk #138, FER-10569) + type: feat + - summary: | + Generated CLIs support `multipart/form-data` request bodies, exposing each + part as its own flag. (cli-sdk #88, FER-10569) + type: feat + - summary: | + `multipart/form-data` request bodies accept `--file` parts, so file-upload + endpoints can stream a file from disk. (cli-sdk #145, FER-10569) + type: feat + - summary: | + HTTP requests now retry on transient failures with exponential backoff and + automatically attach an `Idempotency-Key` header to retried mutations. + (cli-sdk #86, FER-10521) + type: feat + - summary: | + OpenAPI parameter names are sanitized into valid, readable CLI flags instead + of being passed through verbatim. (cli-sdk #87, FER-10430) + type: feat + - summary: | + Auth error messages now name the specific environment variable the CLI + expected, making missing-credential failures easier to diagnose. + (cli-sdk #81, FER-10702) + type: feat + - summary: | + Auth schemes support multiple headers, enabling APIs that require more than + one credential header (e.g. Sandboxes). (cli-sdk #116) + type: feat + - summary: | + Nullable scalar request-body flags emit a null sentinel, letting users + distinguish an explicit `null` from an omitted value. (cli-sdk 8234544) + type: feat + createdAt: "2026-06-02" + irVersion: 67 - version: 0.5.0 changelogEntry: - summary: |