diff --git a/packages/cli/api-importers/graphql/package.json b/packages/cli/api-importers/graphql/package.json index 62cb25f1b8b7..042cba88b256 100644 --- a/packages/cli/api-importers/graphql/package.json +++ b/packages/cli/api-importers/graphql/package.json @@ -31,6 +31,7 @@ "test:update": "vitest --run -u --passWithNoTests" }, "dependencies": { + "@fern-api/core-utils": "workspace:*", "@fern-api/fdr-sdk": "catalog:", "@fern-api/fs-utils": "workspace:*", "@fern-api/task-context": "workspace:*", diff --git a/packages/cli/api-importers/graphql/src/GraphQLConverter.ts b/packages/cli/api-importers/graphql/src/GraphQLConverter.ts index 741dc85705b9..25d5909e3d4b 100644 --- a/packages/cli/api-importers/graphql/src/GraphQLConverter.ts +++ b/packages/cli/api-importers/graphql/src/GraphQLConverter.ts @@ -1,4 +1,5 @@ -import { FdrAPI } from "@fern-api/fdr-sdk"; +import { assertNever } from "@fern-api/core-utils"; +import { FdrAPI, FernNavigation } from "@fern-api/fdr-sdk"; import { AbsoluteFilePath } from "@fern-api/fs-utils"; import { TaskContext } from "@fern-api/task-context"; import { readFile } from "fs/promises"; @@ -13,6 +14,7 @@ import { GraphQLInputType, GraphQLInterfaceType, GraphQLList, + GraphQLNamedType, GraphQLNonNull, GraphQLObjectType, GraphQLOutputType, @@ -26,6 +28,17 @@ import { mergeGraphQlDocuments } from "./mergeGraphQlDocuments.js"; export interface GraphQLConverterResult { graphqlOperations: Record; types: Record; + /** + * The GraphQL kind each documented type was declared with, keyed like `types`. + * + * This is the set of types that get a page, so it is a subset of `types`: an operation + * namespace is left out because its fields are documented as operations, while its definition + * stays in `types` for the namespace query's page to render. Look up with a null check. + * + * The FDR type shape is shared with OpenAPI and gRPC and cannot express a GraphQL kind, so + * the kind travels alongside the types rather than inside them. + */ + typeCategories: Record; } export interface GraphQlExampleInput { @@ -55,6 +68,8 @@ export class GraphQLConverter { private namespace: string | undefined; private processingTypes: Set = new Set(); private types: Record = {}; + private typeCategories: Record = {}; + private namespaceTypeNames: Set = new Set(); private examplesByOperation: Map = new Map(); constructor({ @@ -235,7 +250,14 @@ export class GraphQLConverter { const graphqlOperations = this.resolveOperationIds(pendingOperations); - return { graphqlOperations, types: this.types }; + // A namespace type's fields are documented as operations, so it groups operations rather + // than being a documented type: no kind means no type page. Its definition stays in + // `types` because a namespace query's page renders the nested fields from it. + for (const typeName of this.namespaceTypeNames) { + delete this.typeCategories[this.getNamespacedTypeId(typeName)]; + } + + return { graphqlOperations, types: this.types, typeCategories: this.typeCategories }; } private resolveOperationIds( @@ -289,6 +311,7 @@ export class GraphQLConverter { } const typeId = this.getNamespacedTypeId(typeName); + this.typeCategories[typeId] = this.typeCategoryOf(type); if (type instanceof GraphQLEnumType) { this.processingTypes.add(typeName); @@ -374,6 +397,31 @@ export class GraphQLConverter { } } + // Derived from the declared kind, never from the converted FDR shape: an interface and an + // object both convert to `object` and a custom scalar to `alias`, so shape-sniffing would + // mislabel types. + private typeCategoryOf(type: GraphQLNamedType): FernNavigation.GraphQlTypeCategory { + if (type instanceof GraphQLObjectType) { + return "object"; + } + if (type instanceof GraphQLInputObjectType) { + return "input"; + } + if (type instanceof GraphQLEnumType) { + return "enum"; + } + if (type instanceof GraphQLInterfaceType) { + return "interface"; + } + if (type instanceof GraphQLUnionType) { + return "union"; + } + if (type instanceof GraphQLScalarType) { + return "scalar"; + } + assertNever(type); + } + // Builds an operation id of the form `_`. // Flat (top-level) ids use a single segment; namespaced ids include the full field // path so that fields sharing a leaf name across namespaces resolve to distinct ids. @@ -409,6 +457,7 @@ export class GraphQLConverter { operation: this.convertField(field, fieldName, operationType) }); } + this.namespaceTypeNames.add(returnRawType.name); this.convertNamespaceOperations(returnRawType, operationType, pending, [fieldName]); } else { const flatId = this.buildOperationId(operationType, [fieldName]); @@ -688,21 +737,10 @@ export class GraphQLConverter { }) ); - // Only extend interfaces that are converted to plain objects (no implementations). - // Interfaces with implementations are converted to undiscriminatedUnion, and the - // frontend's unwrapObjectType only supports extending object types. - // GraphQL implementing types already include all interface fields, so extends is - // only needed for documentation purposes when the interface is a plain object. - const interfaces = type.getInterfaces(); - const extendsIds = interfaces - .filter((iface) => { - if (!this.schema) { - return true; - } - const implementations = this.schema.getPossibleTypes(iface); - return implementations.length === 0; - }) - .map((iface) => this.getNamespacedTypeId(iface.name)); + // `extends` carries the `implements` clause, which is the only edge the docs have to + // resolve an interface's implementors. Implementing types already inline every interface + // field, so the extended properties are deduplicated away when rendering. + const extendsIds = type.getInterfaces().map((iface) => this.getNamespacedTypeId(iface.name)); return { type: "object", @@ -712,33 +750,9 @@ export class GraphQLConverter { }; } + // An interface is its own set of fields, not the union of the types that implement it: the + // implementors are reachable from each implementing type's `extends`. private convertInterfaceTypeDefinition(type: GraphQLInterfaceType): FdrAPI.api.v1.register.TypeShape { - if (!this.schema) { - return this.convertInterfaceAsObject(type); - } - - const implementations = this.schema.getPossibleTypes(type); - if (implementations.length === 0) { - return this.convertInterfaceAsObject(type); - } - - return { - type: "undiscriminatedUnion", - variants: implementations.map((impl) => ({ - typeName: impl.name, - displayName: impl.name, - type: { - type: "id", - value: this.getNamespacedTypeId(impl.name), - default: undefined - }, - description: impl.description ?? undefined, - availability: undefined - })) - }; - } - - private convertInterfaceAsObject(type: GraphQLInterfaceType): FdrAPI.api.v1.register.TypeShape { const fields = type.getFields(); const properties: FdrAPI.api.v1.register.ObjectProperty[] = Object.entries(fields).map( ([fieldName, field]) => ({ diff --git a/packages/cli/api-importers/graphql/src/__test__/GraphQLConverter.test.ts b/packages/cli/api-importers/graphql/src/__test__/GraphQLConverter.test.ts index 3e05cfa5dadb..a45c52456b7b 100644 --- a/packages/cli/api-importers/graphql/src/__test__/GraphQLConverter.test.ts +++ b/packages/cli/api-importers/graphql/src/__test__/GraphQLConverter.test.ts @@ -167,3 +167,73 @@ describe("GraphQLConverter custom scalars", () => { expect(graphqlOperations[FdrAPI.GraphQlOperationId("query_users")]?.examples?.[0]?.name).toBe("from spec A"); }); }); + +describe("GraphQLConverter type categories", () => { + const TYPE_CATEGORIES_SCHEMA = join( + FIXTURES_DIR, + RelativeFilePath.of("type-categories"), + RelativeFilePath.of("schema.graphql") + ); + + it("categorizes every emitted type by its GraphQL kind", async () => { + const converter = new GraphQLConverter({ + context: createMockTaskContext(), + filePath: TYPE_CATEGORIES_SCHEMA + }); + + const { types, typeCategories } = await converter.convert(); + + // Asserted as a whole map: a type gaining or losing a category is as much a + // regression as a miscategorized one. `Auditable` is implemented by nothing and + // `Node` by two types, and both stay interfaces — the category comes from the + // declared kind, not from the converted shape (which is `object` for either). + expect(typeCategories).toEqual({ + [FdrAPI.TypeId("Product")]: "object", + [FdrAPI.TypeId("Collection")]: "object", + [FdrAPI.TypeId("ProductCreateInput")]: "input", + [FdrAPI.TypeId("ProductSortKeys")]: "enum", + [FdrAPI.TypeId("DateTime")]: "scalar", + [FdrAPI.TypeId("Node")]: "interface", + [FdrAPI.TypeId("Auditable")]: "interface", + [FdrAPI.TypeId("SearchResult")]: "union" + }); + + // Nothing here is an operation namespace, so every type is documented and the two maps + // line up. See the namespace test below for the case where they deliberately do not. + expect(Object.keys(typeCategories).sort()).toEqual(Object.keys(types).sort()); + }); + + it("gives an operation namespace no category, so it gets no type page", async () => { + const converter = new GraphQLConverter({ + context: createMockTaskContext(), + filePath: join(FIXTURES_DIR, RelativeFilePath.of("namespace-types"), RelativeFilePath.of("schema.graphql")) + }); + + const { types, typeCategories } = await converter.convert(); + + // A namespace type's fields are documented as operations, so listing it as a type too + // would duplicate every one of its fields and arguments on the referenced types' pages. + expect(typeCategories).toEqual({ + [FdrAPI.TypeId("Account")]: "object", + [FdrAPI.TypeId("InventoryLocation")]: "object" + }); + + // The definitions stay: a namespace query's page renders its nested fields from them. + expect(Object.keys(types)).toContain("AccountMutations"); + expect(Object.keys(types)).toContain("InventoryQueries"); + }); + + it("namespaces category keys alongside type ids", async () => { + const converter = new GraphQLConverter({ + context: createMockTaskContext(), + filePath: TYPE_CATEGORIES_SCHEMA, + namespace: "myapi" + }); + + const { types, typeCategories } = await converter.convert(); + + expect(typeCategories[FdrAPI.TypeId("myapi_ProductCreateInput")]).toBe("input"); + expect(typeCategories[FdrAPI.TypeId("ProductCreateInput")]).toBeUndefined(); + expect(Object.keys(typeCategories).sort()).toEqual(Object.keys(types).sort()); + }); +}); diff --git a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/account-schema.json b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/account-schema.json index fb732a8d2e11..1946f4ea3287 100644 --- a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/account-schema.json +++ b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/account-schema.json @@ -1474,7 +1474,9 @@ "name": "Checkout", "shape": { "type": "object", - "extends": [], + "extends": [ + "Node" + ], "properties": [ { "key": "id", @@ -3125,25 +3127,18 @@ "Node": { "name": "Node", "shape": { - "type": "undiscriminatedUnion", - "variants": [ - { - "typeName": "Checkout", - "displayName": "Checkout", - "type": { - "type": "id", - "value": "Checkout" - }, - "description": "A container for all information needed to charge a Merchant for products on a one-time or recurring basis." - }, + "type": "object", + "extends": [], + "properties": [ { - "typeName": "Subscription", - "displayName": "Subscription", - "type": { - "type": "id", - "value": "Subscription" + "key": "id", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } }, - "description": "A one-time or recurring charge for a Product created as a result of a Merchant completing a Checkout." + "description": "The ID of the object." } ] }, @@ -4080,7 +4075,9 @@ "name": "Subscription", "shape": { "type": "object", - "extends": [], + "extends": [ + "Node" + ], "properties": [ { "key": "id", @@ -5397,5 +5394,102 @@ }, "description": "User mutations." } + }, + "typeCategories": { + "Account": "object", + "AccountInfo": "object", + "AddUserToAccountInput": "input", + "AddUserToAccountResult": "object", + "AddUserToStoreInput": "input", + "AddUserToStoreResult": "object", + "AddUsersToUserGroupsInput": "input", + "AddUsersToUserGroupsResult": "object", + "App": "object", + "AppsConnection": "object", + "AppsEdge": "object", + "AwsEventSource": "object", + "AwsEventSourcesConnection": "object", + "AwsEventSourcesEdge": "object", + "AwsRegion": "enum", + "BigDecimal": "scalar", + "CancelSubscriptionInput": "input", + "CancelSubscriptionResult": "object", + "Checkout": "object", + "CheckoutItem": "object", + "CheckoutItemConnection": "object", + "CheckoutItemEdge": "object", + "CheckoutItemInput": "input", + "CheckoutItemPricingInterval": "enum", + "CheckoutItemPricingPlan": "object", + "CheckoutItemProduct": "object", + "CheckoutItemProductInput": "input", + "CheckoutItemProductType": "enum", + "CheckoutItemScope": "object", + "CheckoutItemScopeInput": "input", + "CheckoutItemScopeType": "enum", + "CheckoutItemStatus": "enum", + "CheckoutStatus": "enum", + "CollectionInfo": "object", + "CreateCheckoutInput": "input", + "CreateCheckoutResult": "object", + "CreateEventSourceInput": "input", + "CreateEventSourceResult": "object", + "CreateUserInput": "input", + "CreateUserResult": "object", + "CreateUserWithPasswordInput": "input", + "CreateUserWithPasswordResult": "object", + "CurrencyCode": "enum", + "DateTime": "scalar", + "DeleteEventSourceInput": "input", + "DeleteEventSourceResult": "object", + "Long": "scalar", + "Money": "object", + "MoneyInput": "input", + "Node": "interface", + "PageInfo": "object", + "PricingPlanInput": "input", + "RemoveUserFromAccountInput": "input", + "RemoveUserFromAccountResult": "object", + "RemoveUserFromStoreInput": "input", + "RemoveUserFromStoreResult": "object", + "RemoveUsersFromUserGroupInput": "input", + "RemoveUsersFromUserGroupResult": "object", + "Status": "object", + "Store": "object", + "StoreConnection": "object", + "StoreEdge": "object", + "StoreFilterInput": "input", + "StoreStatus": "object", + "StoreStatusEnum": "enum", + "StoreUser": "object", + "StoreUserConnection": "object", + "StoreUserEdge": "object", + "StoreUserStatus": "enum", + "Subscription": "object", + "SubscriptionBillingInterval": "enum", + "SubscriptionConnection": "object", + "SubscriptionEdge": "object", + "SubscriptionFiltersInput": "input", + "SubscriptionProduct": "object", + "SubscriptionProductType": "enum", + "SubscriptionScope": "object", + "SubscriptionScopeType": "enum", + "SubscriptionStatus": "enum", + "System": "object", + "TargetByUserGroup": "object", + "TargetConnection": "object", + "TargetEdge": "object", + "TargetType": "enum", + "UUID": "scalar", + "User": "object", + "UserConnection": "object", + "UserEdge": "object", + "UserGroup": "object", + "UserGroupByUser": "object", + "UserGroupByUserConnection": "object", + "UserGroupByUserEdge": "object", + "UserGroupConnection": "object", + "UserGroupEdge": "object", + "UserIdentifierInput": "input" } } \ No newline at end of file diff --git a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/basic.json b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/basic.json index 2dea69649886..f02b67198218 100644 --- a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/basic.json +++ b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/basic.json @@ -920,5 +920,24 @@ }, "description": "Input for filtering users" } + }, + "typeCategories": { + "DateTime": "scalar", + "Date": "scalar", + "Email": "scalar", + "URL": "scalar", + "UUID": "scalar", + "JSON": "scalar", + "Upload": "scalar", + "BigInt": "scalar", + "Decimal": "scalar", + "User": "object", + "Post": "object", + "UserRole": "enum", + "PostStatus": "enum", + "SearchResult": "union", + "CreateUserInput": "input", + "CreatePostInput": "input", + "UserFilter": "input" } } \ No newline at end of file diff --git a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/federated-subgraphs.json b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/federated-subgraphs.json index 79d4b8af6648..e2b6f28d54d2 100644 --- a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/federated-subgraphs.json +++ b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/federated-subgraphs.json @@ -505,5 +505,17 @@ ] } } + }, + "typeCategories": { + "UserProfile": "object", + "UserProfileFilter": "input", + "UpdateUserProfileInput": "input", + "ProfileStatus": "enum", + "ProfileImages": "object", + "Image": "object", + "ImageInput": "input", + "ImageFilter": "input", + "ProductUsage": "object", + "UsagePeriod": "enum" } } \ No newline at end of file diff --git a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/interface-field-args.json b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/interface-field-args.json index edce5bf9f22a..2760eded4a32 100644 --- a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/interface-field-args.json +++ b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/interface-field-args.json @@ -151,5 +151,9 @@ }, "description": "An image source interface with no implementing types.\nSome fields have arguments to exercise argument capture on orphaned interfaces." } + }, + "typeCategories": { + "ImageFormat": "enum", + "ImageSource": "interface" } } \ No newline at end of file diff --git a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/interfaces.json b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/interfaces.json index c9b6e754f368..1e2d8e87ff6e 100644 --- a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/interfaces.json +++ b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/interfaces.json @@ -101,34 +101,38 @@ "ProductOption": { "name": "ProductOption", "shape": { - "type": "undiscriminatedUnion", - "variants": [ + "type": "object", + "extends": [], + "properties": [ { - "typeName": "CheckboxOption", - "displayName": "CheckboxOption", - "type": { - "type": "id", - "value": "CheckboxOption" + "key": "entityId", + "valueType": { + "type": "primitive", + "value": { + "type": "integer" + } }, - "description": "A checkbox option with checked state." + "description": "Unique ID for the option." }, { - "typeName": "MultipleChoiceOption", - "displayName": "MultipleChoiceOption", - "type": { - "type": "id", - "value": "MultipleChoiceOption" + "key": "displayName", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } }, - "description": "A multiple choice option with selectable values." + "description": "Display name for the option." }, { - "typeName": "TextFieldOption", - "displayName": "TextFieldOption", - "type": { - "type": "id", - "value": "TextFieldOption" + "key": "isRequired", + "valueType": { + "type": "primitive", + "value": { + "type": "boolean" + } }, - "description": "A text field option for free-form input." + "description": "Whether the option is required." } ] }, @@ -138,7 +142,9 @@ "name": "CheckboxOption", "shape": { "type": "object", - "extends": [], + "extends": [ + "ProductOption" + ], "properties": [ { "key": "checkedByDefault", @@ -198,7 +204,9 @@ "name": "MultipleChoiceOption", "shape": { "type": "object", - "extends": [], + "extends": [ + "ProductOption" + ], "properties": [ { "key": "displayStyle", @@ -261,7 +269,9 @@ "name": "TextFieldOption", "shape": { "type": "object", - "extends": [], + "extends": [ + "ProductOption" + ], "properties": [ { "key": "defaultValue", @@ -326,25 +336,18 @@ "Node": { "name": "Node", "shape": { - "type": "undiscriminatedUnion", - "variants": [ - { - "typeName": "Product", - "displayName": "Product", - "type": { - "type": "id", - "value": "Product" - }, - "description": "A product that implements the Node interface." - }, + "type": "object", + "extends": [], + "properties": [ { - "typeName": "Category", - "displayName": "Category", - "type": { - "type": "id", - "value": "Category" + "key": "id", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } }, - "description": "A category that implements the Node interface." + "description": "The id of the object." } ] }, @@ -354,7 +357,9 @@ "name": "Product", "shape": { "type": "object", - "extends": [], + "extends": [ + "Node" + ], "properties": [ { "key": "id", @@ -395,7 +400,9 @@ "name": "Category", "shape": { "type": "object", - "extends": [], + "extends": [ + "Node" + ], "properties": [ { "key": "id", @@ -478,5 +485,16 @@ }, "description": "An interface with no implementations." } + }, + "typeCategories": { + "ProductOption": "interface", + "CheckboxOption": "object", + "MultipleChoiceOption": "object", + "TextFieldOption": "object", + "Node": "interface", + "Product": "object", + "Category": "object", + "SearchResult": "union", + "OrphanInterface": "interface" } } \ No newline at end of file diff --git a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/namespace-collisions.json b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/namespace-collisions.json index ba738040d421..9f08197b9174 100644 --- a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/namespace-collisions.json +++ b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/namespace-collisions.json @@ -317,5 +317,8 @@ }, "description": "A second query namespace that shares the `search` child field name with InventoryQueries." } + }, + "typeCategories": { + "Widget": "object" } } \ No newline at end of file diff --git a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/namespace-types.json b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/namespace-types.json index b99a269a8e40..ed8521244116 100644 --- a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/namespace-types.json +++ b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/namespace-types.json @@ -303,5 +303,9 @@ }, "description": "Namespace grouping for inventory queries.\nAll fields have arguments — this makes it a namespace type.\nA parent operation should be created since the root Query field has no arguments." } + }, + "typeCategories": { + "Account": "object", + "InventoryLocation": "object" } } \ No newline at end of file diff --git a/packages/cli/api-importers/graphql/src/__test__/__snapshots__/type-categories.json b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/type-categories.json new file mode 100644 index 000000000000..2e7c7d00670e --- /dev/null +++ b/packages/cli/api-importers/graphql/src/__test__/__snapshots__/type-categories.json @@ -0,0 +1,309 @@ +{ + "graphqlOperations": { + "query_product": { + "id": "query_product", + "operationType": "QUERY", + "name": "product", + "description": "Fetch a product by id.", + "arguments": [ + { + "name": "id", + "type": { + "type": "primitive", + "value": { + "type": "string" + } + } + } + ], + "returnType": { + "type": "optional", + "itemType": { + "type": "id", + "value": "Product" + } + } + }, + "query_products": { + "id": "query_products", + "operationType": "QUERY", + "name": "products", + "description": "List products.", + "arguments": [ + { + "name": "sortKey", + "type": { + "type": "optional", + "itemType": { + "type": "id", + "value": "ProductSortKeys" + } + } + } + ], + "returnType": { + "type": "list", + "itemType": { + "type": "id", + "value": "Product" + } + } + }, + "query_search": { + "id": "query_search", + "operationType": "QUERY", + "name": "search", + "description": "Search products and collections.", + "arguments": [ + { + "name": "query", + "type": { + "type": "primitive", + "value": { + "type": "string" + } + } + } + ], + "returnType": { + "type": "list", + "itemType": { + "type": "id", + "value": "SearchResult" + } + } + }, + "mutation_productCreate": { + "id": "mutation_productCreate", + "operationType": "MUTATION", + "name": "productCreate", + "description": "Create a product.", + "arguments": [ + { + "name": "input", + "type": { + "type": "id", + "value": "ProductCreateInput" + } + } + ], + "returnType": { + "type": "optional", + "itemType": { + "type": "id", + "value": "Product" + } + } + } + }, + "types": { + "DateTime": { + "name": "DateTime", + "shape": { + "type": "alias", + "value": { + "type": "primitive", + "value": { + "type": "datetime" + } + } + }, + "description": "An ISO-8601 timestamp." + }, + "ProductSortKeys": { + "name": "ProductSortKeys", + "shape": { + "type": "enum", + "values": [ + { + "value": "TITLE", + "description": "Sort by title." + }, + { + "value": "CREATED_AT", + "description": "Sort by creation date." + } + ] + }, + "description": "How a list of products is sorted." + }, + "Node": { + "name": "Node", + "shape": { + "type": "object", + "extends": [], + "properties": [ + { + "key": "id", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } + }, + "description": "The id of the object." + } + ] + }, + "description": "Anything addressable by a global id." + }, + "Auditable": { + "name": "Auditable", + "shape": { + "type": "object", + "extends": [], + "properties": [ + { + "key": "updatedAt", + "valueType": { + "type": "id", + "value": "DateTime" + }, + "description": "When the record last changed." + } + ] + }, + "description": "An interface no type implements, so it only ever appears as a type of its own." + }, + "Product": { + "name": "Product", + "shape": { + "type": "object", + "extends": [ + "Node" + ], + "properties": [ + { + "key": "id", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } + }, + "description": "The id of the object." + }, + { + "key": "title", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } + }, + "description": "The product title." + }, + { + "key": "createdAt", + "valueType": { + "type": "id", + "value": "DateTime" + }, + "description": "When the product was created." + } + ] + }, + "description": "A product for sale." + }, + "Collection": { + "name": "Collection", + "shape": { + "type": "object", + "extends": [ + "Node" + ], + "properties": [ + { + "key": "id", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } + }, + "description": "The id of the object." + }, + { + "key": "title", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } + }, + "description": "The collection title." + } + ] + }, + "description": "A collection of products." + }, + "SearchResult": { + "name": "SearchResult", + "shape": { + "type": "undiscriminatedUnion", + "variants": [ + { + "typeName": "Product", + "displayName": "Product", + "type": { + "type": "id", + "value": "Product" + }, + "description": "A product for sale." + }, + { + "typeName": "Collection", + "displayName": "Collection", + "type": { + "type": "id", + "value": "Collection" + }, + "description": "A collection of products." + } + ] + }, + "description": "Anything returned by a search." + }, + "ProductCreateInput": { + "name": "ProductCreateInput", + "shape": { + "type": "object", + "extends": [], + "properties": [ + { + "key": "title", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } + }, + "description": "The product title." + }, + { + "key": "publishAt", + "valueType": { + "type": "optional", + "itemType": { + "type": "id", + "value": "DateTime" + } + }, + "description": "When the product should be published." + } + ] + }, + "description": "Fields used to create a product." + } + }, + "typeCategories": { + "DateTime": "scalar", + "ProductSortKeys": "enum", + "Node": "interface", + "Auditable": "interface", + "Product": "object", + "Collection": "object", + "SearchResult": "union", + "ProductCreateInput": "input" + } +} \ No newline at end of file diff --git a/packages/cli/api-importers/graphql/src/__test__/fixtures/interface-field-args/schema.graphql b/packages/cli/api-importers/graphql/src/__test__/fixtures/interface-field-args/schema.graphql index a2c7e784893d..126aec4dd251 100644 --- a/packages/cli/api-importers/graphql/src/__test__/fixtures/interface-field-args/schema.graphql +++ b/packages/cli/api-importers/graphql/src/__test__/fixtures/interface-field-args/schema.graphql @@ -1,6 +1,6 @@ # GraphQL schema to test field-level arguments on interface types with no implementations. -# convertInterfaceAsObject is used when an interface has zero implementing types; -# this fixture ensures those field arguments are included in the output. +# An interface is converted to an object shape, so this fixture ensures its field +# arguments are included in the output even when nothing implements it. """ Supported image formats. diff --git a/packages/cli/api-importers/graphql/src/__test__/fixtures/type-categories/schema.graphql b/packages/cli/api-importers/graphql/src/__test__/fixtures/type-categories/schema.graphql new file mode 100644 index 000000000000..66187bd679eb --- /dev/null +++ b/packages/cli/api-importers/graphql/src/__test__/fixtures/type-categories/schema.graphql @@ -0,0 +1,121 @@ +# GraphQL schema exercising every type category: object, input, enum, scalar, +# interface (implemented and orphaned) and union. + +""" +An ISO-8601 timestamp. +""" +scalar DateTime + +""" +How a list of products is sorted. +""" +enum ProductSortKeys { + """ + Sort by title. + """ + TITLE + + """ + Sort by creation date. + """ + CREATED_AT +} + +""" +Anything addressable by a global id. +""" +interface Node { + """ + The id of the object. + """ + id: ID! +} + +""" +An interface no type implements, so it only ever appears as a type of its own. +""" +interface Auditable { + """ + When the record last changed. + """ + updatedAt: DateTime! +} + +""" +A product for sale. +""" +type Product implements Node { + """ + The id of the object. + """ + id: ID! + + """ + The product title. + """ + title: String! + + """ + When the product was created. + """ + createdAt: DateTime! +} + +""" +A collection of products. +""" +type Collection implements Node { + """ + The id of the object. + """ + id: ID! + + """ + The collection title. + """ + title: String! +} + +""" +Anything returned by a search. +""" +union SearchResult = Product | Collection + +""" +Fields used to create a product. +""" +input ProductCreateInput { + """ + The product title. + """ + title: String! + + """ + When the product should be published. + """ + publishAt: DateTime +} + +type Query { + """ + Fetch a product by id. + """ + product(id: ID!): Product + + """ + List products. + """ + products(sortKey: ProductSortKeys): [Product!]! + + """ + Search products and collections. + """ + search(query: String!): [SearchResult!]! +} + +type Mutation { + """ + Create a product. + """ + productCreate(input: ProductCreateInput!): Product +} diff --git a/packages/cli/cli/changes/unreleased/graphql-interface-implementors.yml b/packages/cli/cli/changes/unreleased/graphql-interface-implementors.yml new file mode 100644 index 000000000000..557da77cc169 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/graphql-interface-implementors.yml @@ -0,0 +1,4 @@ +- summary: | + Convert a GraphQL interface to its own set of fields and record each `implements` clause on the + implementing type, so docs can list an interface's fields and the types that implement it. + type: fix diff --git a/packages/cli/cli/changes/unreleased/graphql-namespace-type-pages.yml b/packages/cli/cli/changes/unreleased/graphql-namespace-type-pages.yml new file mode 100644 index 000000000000..50ef455199de --- /dev/null +++ b/packages/cli/cli/changes/unreleased/graphql-namespace-type-pages.yml @@ -0,0 +1,5 @@ +- summary: | + Don't document GraphQL operation-namespace types (e.g. a `Mutation.checkout: CheckoutMutations` + grouping type) as types of their own. Their fields are already documented as operations, so a + type page for them duplicated every field and argument on the referenced types' pages. + type: fix diff --git a/packages/cli/cli/changes/unreleased/graphql-type-categories.yml b/packages/cli/cli/changes/unreleased/graphql-type-categories.yml new file mode 100644 index 000000000000..2d5e5c53527a --- /dev/null +++ b/packages/cli/cli/changes/unreleased/graphql-type-categories.yml @@ -0,0 +1,4 @@ +- summary: | + Record the GraphQL kind (object, input, enum, scalar, interface, union) of every named type + read from a GraphQL schema, so docs can render a Types section. + type: feat diff --git a/packages/cli/cli/changes/unreleased/graphql-type-navigation.yml b/packages/cli/cli/changes/unreleased/graphql-type-navigation.yml new file mode 100644 index 000000000000..4fded756be00 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/graphql-type-navigation.yml @@ -0,0 +1,7 @@ +- summary: | + GraphQL API references now get a page per named type in the schema, collected under a single + "Types" section and grouped within it by the kind each type was declared with (Objects, + Inputs, Enums, Scalars, Interfaces, Unions). Every GraphQL spec in the API section contributes + to that one section, and it sits at the API root rather than under a subpackage. A kind the + schema does not declare produces no group, and type pages live at `/types//`. + type: feat diff --git a/packages/cli/docs-resolver/src/ApiReferenceNodeConverter.ts b/packages/cli/docs-resolver/src/ApiReferenceNodeConverter.ts index a593c9617320..e655351ce877 100644 --- a/packages/cli/docs-resolver/src/ApiReferenceNodeConverter.ts +++ b/packages/cli/docs-resolver/src/ApiReferenceNodeConverter.ts @@ -59,6 +59,34 @@ import { toRelativeFilepath } from "./utils/toRelativeFilepath.js"; const NUM_NEAREST_SUBPACKAGES = 1; +/** + * One sidebar section per GraphQL kind. A kind with no types in the schema produces no section, + * so a schema without unions never renders an empty "Unions" group. + * + * `urlSlug` is part of the public docs URL (`.../types/objects/`) and must stay stable. + */ +const GRAPHQL_TYPE_SECTIONS: { category: FernNavigation.GraphQlTypeCategory; title: string; urlSlug: string }[] = [ + { category: "object", title: "Objects", urlSlug: "objects" }, + { category: "input", title: "Inputs", urlSlug: "inputs" }, + { category: "enum", title: "Enums", urlSlug: "enums" }, + { category: "scalar", title: "Scalars", urlSlug: "scalars" }, + { category: "interface", title: "Interfaces", urlSlug: "interfaces" }, + { category: "union", title: "Unions", urlSlug: "unions" } +]; + +/** + * The GraphQL type-page member of {@link FernNavigation.V1.ApiPackageChild}. + */ +type GraphqlTypeChildNode = Extract; + +const GRAPHQL_TYPES_URL_SLUG = "types"; + +/** + * Title of the single section holding every GraphQL kind. Types belong to the schema rather than + * to any one package, so every GraphQL spec in the API section contributes to this one section. + */ +const GRAPHQL_TYPES_TITLE = "Types"; + export class ApiReferenceNodeConverter { apiDefinitionId: FernNavigation.V1.ApiDefinitionId; #holder: ApiDefinitionHolder; @@ -77,6 +105,7 @@ export class ApiReferenceNodeConverter { private collectedFileIds = new Map(); #tagDescriptionContent: Map; #graphqlNamespacesByOperationId: Map; + #graphqlTypeCategories: Record; constructor( private apiSection: docsYml.DocsNavigationItem.ApiSection, api: APIV1Read.ApiDefinition, @@ -92,10 +121,12 @@ export class ApiReferenceNodeConverter { private hideChildren?: boolean, private parentAvailability?: docsYml.RawSchemas.Availability, private openApiTags?: Record, - graphqlNamespacesByOperationId?: Map + graphqlNamespacesByOperationId?: Map, + graphqlTypeCategories?: Record ) { this.#tagDescriptionContent = new Map(); this.#graphqlNamespacesByOperationId = graphqlNamespacesByOperationId ?? new Map(); + this.#graphqlTypeCategories = graphqlTypeCategories ?? {}; this.disableEndpointPairs = docsWorkspace.config.experimental?.disableStreamToggle ?? false; this.apiDefinitionId = FernNavigation.V1.ApiDefinitionId(api.id); this.#holder = ApiDefinitionHolder.create(api, taskContext); @@ -1132,6 +1163,16 @@ export class ApiReferenceNodeConverter { additionalChildren.push(...graphqlSections); } + // GraphQL types belong to the schema, not to any one package, so they are emitted once at + // the API root. Emitting from whichever package happened to be converted first would nest + // them under an unrelated REST tag and give them that tag's slug. + if (pkg === this.#holder.api.rootPackage) { + const graphqlTypesSection = this.#convertGraphQLTypesToSection(parentSlug, parentAvailability); + if (graphqlTypesSection != null) { + additionalChildren.push(graphqlTypesSection); + } + } + additionalChildren = this.mergeEndpointPairs(additionalChildren); if (this.apiSection.alphabetized) { @@ -1307,6 +1348,138 @@ export class ApiReferenceNodeConverter { return sections; } + /** + * Builds the GraphQL types navigation: a single "Types" section holding one sub-section + * per GraphQL kind, each with one page per named type declared with that kind. Every GraphQL + * spec in the API section contributes to this one section. + * + * Slugs are `/types//`. Type IDs are already namespace-prefixed by the + * GraphQL converter, so same-named types from two schemas get distinct slugs. + */ + #convertGraphQLTypesToSection( + parentSlug: FernNavigation.V1.SlugGenerator, + parentAvailability?: docsYml.RawSchemas.Availability + ): FernNavigation.V1.ApiPackageChild | undefined { + const typeIdsByCategory = new Map(); + for (const [typeIdRaw, category] of Object.entries(this.#graphqlTypeCategories)) { + const typeId = FdrAPI.TypeId(typeIdRaw); + if (this.#holder.api.types[typeId] == null) { + continue; + } + const existing = typeIdsByCategory.get(category); + if (existing != null) { + existing.push(typeId); + } else { + typeIdsByCategory.set(category, [typeId]); + } + } + + if (typeIdsByCategory.size === 0) { + return undefined; + } + + const typesSlug = parentSlug.append(GRAPHQL_TYPES_URL_SLUG); + const sections: FernNavigation.V1.ApiPackageChild[] = []; + + for (const { category, title, urlSlug } of GRAPHQL_TYPE_SECTIONS) { + const typeIds = typeIdsByCategory.get(category); + if (typeIds == null || typeIds.length === 0) { + continue; + } + + const sectionSlug = typesSlug.append(urlSlug); + const children: FernNavigation.V1.ApiPackageChild[] = typeIds + .map((typeId) => this.#buildGraphqlTypeChildNode(typeId, category, sectionSlug, parentAvailability)) + .filter(isNonNullish) + .sort((a, b) => a.title.localeCompare(b.title)); + + if (children.length === 0) { + continue; + } + + sections.push({ + id: this.#idgen.get(`${this.apiDefinitionId}:graphql:types:${category}`), + type: "apiPackage", + collapsed: undefined, + children, + title, + slug: sectionSlug.get(), + icon: undefined, + hidden: this.hideChildren, + overviewPageId: undefined, + collapsible: undefined, + collapsedByDefault: undefined, + availability: convertDocsAvailability(parentAvailability), + apiDefinitionId: this.apiDefinitionId, + pointsTo: undefined, + noindex: undefined, + playground: undefined, + authed: undefined, + viewers: undefined, + orphaned: undefined, + featureFlags: undefined + } as ApiPackageNodeWithCollapsibleConfig); + } + + if (sections.length === 0) { + return undefined; + } + + return { + id: this.#idgen.get(`${this.apiDefinitionId}:graphql:types`), + type: "apiPackage", + collapsed: undefined, + children: sections, + title: GRAPHQL_TYPES_TITLE, + slug: typesSlug.get(), + icon: undefined, + hidden: this.hideChildren, + overviewPageId: undefined, + collapsible: undefined, + collapsedByDefault: undefined, + availability: convertDocsAvailability(parentAvailability), + apiDefinitionId: this.apiDefinitionId, + pointsTo: undefined, + noindex: undefined, + playground: undefined, + authed: undefined, + viewers: undefined, + orphaned: undefined, + featureFlags: undefined + } as ApiPackageNodeWithCollapsibleConfig; + } + + #buildGraphqlTypeChildNode( + typeId: FdrAPI.TypeId, + typeCategory: FernNavigation.GraphQlTypeCategory, + sectionSlug: FernNavigation.V1.SlugGenerator, + parentAvailability?: docsYml.RawSchemas.Availability + ): GraphqlTypeChildNode | undefined { + const type = this.#holder.api.types[typeId]; + if (type == null) { + return undefined; + } + const title = type.displayName ?? type.name ?? typeId; + return { + id: this.#idgen.get(`${this.apiDefinitionId}:graphqlType:${typeId}`), + type: "graphqlType", + collapsed: undefined, + typeId: FernNavigation.TypeId(typeId), + typeCategory, + title, + slug: sectionSlug.append(kebabCase(title)).get(), + icon: undefined, + hidden: this.hideChildren, + apiDefinitionId: this.apiDefinitionId, + availability: + FernNavigation.V1.convertAvailability(type.availability) ?? convertDocsAvailability(parentAvailability), + authed: undefined, + viewers: undefined, + orphaned: undefined, + featureFlags: undefined + }; + } + // Single source of truth for constructing a GraphQL navigation child node. Callers pass // the fields that vary; the fixed shape (type, apiDefinitionId, authed, ...) and the // availability conversion live here. Optional fields left unset serialize as `undefined`, diff --git a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts index 2a3471a610fe..b431c9a637ba 100644 --- a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts +++ b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts @@ -2010,7 +2010,8 @@ export class DocsDefinitionResolver { hideChildren, parentAvailability ?? item.availability, openApiTags, - graphqlData.namespacesByOperationId + graphqlData.namespacesByOperationId, + graphqlData.typeCategories ); // Extract tag description content and add it to both rawMarkdownFiles and parsedDocsConfig.pages @@ -2109,13 +2110,19 @@ export class DocsDefinitionResolver { operations: Record; types: Record; namespacesByOperationId: Map; + /** + * The GraphQL kind of every named type, keyed like `types`. Drives the per-category + * sections of the GraphQL Types navigation; types absent from this map get no page. + */ + typeCategories: Record; }> { const graphqlOperations: Record = {}; const graphqlTypes: Record = {}; const namespacesByOperationId = new Map(); + const typeCategories: Record = {}; if (workspace == null) { - return { operations: graphqlOperations, types: graphqlTypes, namespacesByOperationId }; + return { operations: graphqlOperations, types: graphqlTypes, namespacesByOperationId, typeCategories }; } const graphqlSpecs = workspace.allSpecs.filter((spec): spec is GraphQLSpec => spec.type === "graphql"); @@ -2140,6 +2147,7 @@ export class DocsDefinitionResolver { Object.assign(graphqlOperations, graphqlResult.graphqlOperations); Object.assign(graphqlTypes, graphqlResult.types); + Object.assign(typeCategories, graphqlResult.typeCategories); if (namespace) { for (const operationId of Object.keys(graphqlResult.graphqlOperations)) { @@ -2154,7 +2162,7 @@ export class DocsDefinitionResolver { } } - return { operations: graphqlOperations, types: graphqlTypes, namespacesByOperationId }; + return { operations: graphqlOperations, types: graphqlTypes, namespacesByOperationId, typeCategories }; } private async loadGraphQlExamples( diff --git a/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/definition/schema.graphql b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/definition/schema.graphql new file mode 100644 index 000000000000..8ba878537257 --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/definition/schema.graphql @@ -0,0 +1,40 @@ +"""A point in time, serialized as an ISO-8601 string.""" +scalar DateTime + +"""How a product list is ordered.""" +enum ProductSortKeys { + TITLE + CREATED_AT +} + +"""Anything addressable by a global id.""" +interface Node { + id: ID! +} + +type Product implements Node { + id: ID! + title: String! + createdAt: DateTime! +} + +type Collection implements Node { + id: ID! + title: String! +} + +input ProductInput { + title: String! + sortKey: ProductSortKeys +} + +union SearchResult = Product | Collection + +type Query { + products(sortKey: ProductSortKeys, first: Int): [Product!]! + search(query: String!): [SearchResult!]! +} + +type Mutation { + productCreate(input: ProductInput!): Product +} diff --git a/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/docs.yml b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/docs.yml new file mode 100644 index 000000000000..805ea9cc8b77 --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/docs.yml @@ -0,0 +1,4 @@ +instances: [] + +navigation: + - api: GraphQL API Reference diff --git a/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/fern.config.json b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/fern.config.json new file mode 100644 index 000000000000..d2854453ac75 --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/fern.config.json @@ -0,0 +1,5 @@ +{ + "version": "0.30.0", + "organization": "fern-test", + "project-name": "graphql-type-navigation" +} diff --git a/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/generators.yml b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/generators.yml new file mode 100644 index 000000000000..a9169572a77d --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/generators.yml @@ -0,0 +1,12 @@ +default-group: docs + +api: + specs: + - graphql: ./definition/schema.graphql + - openapi: ./openapi.yml + +groups: + docs: + generators: + - name: fern-docs + version: latest diff --git a/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/openapi.yml b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/openapi.yml new file mode 100644 index 000000000000..b5c9972e74b1 --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation-subpackages/fern/openapi.yml @@ -0,0 +1,385 @@ +openapi: 3.1.0 +info: + title: Swagger Petstore - OpenAPI 3.1 + description: |- + This is a sample Pet Store Server based on the OpenAPI 3.1 specification. + You can find out more about + Swagger at [http://swagger.io](http://swagger.io). + summary: Pet Store 3.1 + version: 1.0.0 +servers: + - url: /api/v31 +tags: + - name: pet + description: |- + Everything about your Pets. + + Use the `{petId}` path parameter to identify a specific pet. + For example: `GET /pets/{petId}` + + ```json + { + "id": 10, + "name": "doggie", + "status": "available" + } + ``` + + Filter pets with query params like `status=available` or `tags=`. + - name: store + description: Access to Petstore orders + - name: user + description: Operations about user + - name: Study Collections + description: Manage study collections and their contents + - name: user-management + description: User management operations +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update an existing pet by Id + operationId: updatePet + requestBody: + description: Pet object that needs to be updated in the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + description: A Pet in JSON Format + required: + - id + writeOnly: true + application/xml: + schema: + $ref: "#/components/schemas/Pet" + description: A Pet in XML Format + required: + - id + writeOnly: true + required: true + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Pet" + description: A Pet in XML Format + readOnly: true + application/json: + schema: + $ref: "#/components/schemas/Pet" + description: A Pet in JSON Format + readOnly: true + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "405": + description: Validation exception + security: + - petstore_auth: + - "write:pets" + - "read:pets" + post: + tags: + - pet + summary: Add a new pet to the store + description: Add a new pet to the store + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + description: A Pet in JSON Format + required: + - id + writeOnly: true + application/xml: + schema: + $ref: "#/components/schemas/Pet" + description: A Pet in XML Format + required: + - id + writeOnly: true + required: true + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Pet" + description: A Pet in XML Format + readOnly: true + application/json: + schema: + $ref: "#/components/schemas/Pet" + description: A Pet in JSON Format + readOnly: true + "405": + description: Invalid input + security: + - petstore_auth: + - "write:pets" + - "read:pets" + "/pet/{petId}": + get: + tags: + - pets + summary: Find pet by ID + description: >- + Returns a pet when 0 < ID <= 10. ID > 10 or nonintegers will simulate + API error conditions + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet that needs to be fetched + required: true + schema: + type: integer + format: int64 + description: param ID of pet that needs to be fetched + exclusiveMaximum: 10 + exclusiveMinimum: 1 + responses: + "400": + description: Invalid ID supplied + "404": + description: Pet not found + default: + description: The pet + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + description: A Pet in JSON format + application/xml: + schema: + $ref: "#/components/schemas/Pet" + description: A Pet in XML format + security: + - petstore_auth: + - "write:pets" + - "read:pets" + - api_key: [] + /study-collections: + get: + tags: + - Study Collections + summary: List all study collections + operationId: listStudyCollections + responses: + "200": + description: Successful operation + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + /user-management/users: + get: + tags: + - user-management + summary: List all users + operationId: listUsers + responses: + "200": + description: Successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" +components: + schemas: + Order: + x-swagger-router-model: io.swagger.petstore.model.Order + properties: + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + example: approved + complete: + type: boolean + xml: + name: order + type: object + Customer: + properties: + id: + type: integer + format: int64 + example: 100000 + username: + type: string + example: fehguy + address: + type: array + items: + $ref: "#/components/schemas/Address" + xml: + wrapped: true + name: addresses + xml: + name: customer + type: object + Address: + properties: + street: + type: string + example: 437 Lytton + city: + type: string + example: Palo Alto + state: + type: string + example: CA + zip: + type: string + example: 94301 + xml: + name: address + type: object + Category: + x-swagger-router-model: io.swagger.petstore.model.Category + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + xml: + name: category + type: object + User: + x-swagger-router-model: io.swagger.petstore.model.User + properties: + id: + type: integer + format: int64 + example: 10 + username: + type: string + example: theUser + firstName: + type: string + example: John + lastName: + type: string + example: James + email: + type: string + example: john@email.com + password: + type: string + example: 12345 + phone: + type: string + example: 12345 + userStatus: + type: integer + format: int32 + example: 1 + description: User Status + xml: + name: user + type: object + Tag: + x-swagger-router-model: io.swagger.petstore.model.Tag + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + type: object + Pet: + x-swagger-router-model: io.swagger.petstore.model.Pet + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + category: + $ref: "#/components/schemas/Category" + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + $ref: "#/components/schemas/Tag" + xml: + name: tag + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: pet + type: object + ApiResponse: + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + xml: + name: "##default" + type: object diff --git a/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/definition/schema.graphql b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/definition/schema.graphql new file mode 100644 index 000000000000..8ba878537257 --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/definition/schema.graphql @@ -0,0 +1,40 @@ +"""A point in time, serialized as an ISO-8601 string.""" +scalar DateTime + +"""How a product list is ordered.""" +enum ProductSortKeys { + TITLE + CREATED_AT +} + +"""Anything addressable by a global id.""" +interface Node { + id: ID! +} + +type Product implements Node { + id: ID! + title: String! + createdAt: DateTime! +} + +type Collection implements Node { + id: ID! + title: String! +} + +input ProductInput { + title: String! + sortKey: ProductSortKeys +} + +union SearchResult = Product | Collection + +type Query { + products(sortKey: ProductSortKeys, first: Int): [Product!]! + search(query: String!): [SearchResult!]! +} + +type Mutation { + productCreate(input: ProductInput!): Product +} diff --git a/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/docs.yml b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/docs.yml new file mode 100644 index 000000000000..805ea9cc8b77 --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/docs.yml @@ -0,0 +1,4 @@ +instances: [] + +navigation: + - api: GraphQL API Reference diff --git a/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/fern.config.json b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/fern.config.json new file mode 100644 index 000000000000..d2854453ac75 --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/fern.config.json @@ -0,0 +1,5 @@ +{ + "version": "0.30.0", + "organization": "fern-test", + "project-name": "graphql-type-navigation" +} diff --git a/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/generators.yml b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/generators.yml new file mode 100644 index 000000000000..ae768792e054 --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/fixtures/graphql-type-navigation/fern/generators.yml @@ -0,0 +1,11 @@ +default-group: docs + +api: + specs: + - graphql: ./definition/schema.graphql + +groups: + docs: + generators: + - name: fern-docs + version: latest diff --git a/packages/cli/docs-resolver/src/__test__/graphql-type-navigation.test.ts b/packages/cli/docs-resolver/src/__test__/graphql-type-navigation.test.ts new file mode 100644 index 000000000000..7726fe3bfba8 --- /dev/null +++ b/packages/cli/docs-resolver/src/__test__/graphql-type-navigation.test.ts @@ -0,0 +1,249 @@ +import { SourceResolverImpl } from "@fern-api/cli-source-resolver"; +import { parseDocsConfiguration } from "@fern-api/configuration-loader"; +import { FdrAPI, FernNavigation } from "@fern-api/fdr-sdk"; +import { AbsoluteFilePath, resolve } from "@fern-api/fs-utils"; +import { GraphQLConverter } from "@fern-api/graphql-to-fdr"; +import { generateIntermediateRepresentation } from "@fern-api/ir-generator"; +import { createMockTaskContext } from "@fern-api/task-context"; +import { loadAPIWorkspace, loadDocsWorkspace } from "@fern-api/workspace-loader"; + +import { ApiReferenceNodeConverter } from "../ApiReferenceNodeConverter.js"; +import { NodeIdGenerator } from "../NodeIdGenerator.js"; +import { convertIrToApiDefinition } from "../utils/convertIrToApiDefinition.js"; + +const context = createMockTaskContext(); + +const apiDefinitionId = "550e8400-e29b-41d4-a716-446655440000"; + +const FIXTURE = "fixtures/graphql-type-navigation/fern"; + +/** Same schema, but the API section also has an OpenAPI spec whose tags become subpackages. */ +const FIXTURE_WITH_SUBPACKAGES = "fixtures/graphql-type-navigation-subpackages/fern"; + +async function convertFixture({ + namespace, + fixture = FIXTURE +}: { + namespace?: string; + fixture?: string; +} = {}): Promise { + const fernDirectory = resolve(AbsoluteFilePath.of(__dirname), fixture); + + const docsWorkspace = await loadDocsWorkspace({ fernDirectory, context }); + if (docsWorkspace == null) { + throw new Error("Docs workspace is null"); + } + + const parsedDocsConfig = await parseDocsConfiguration({ + rawDocsConfiguration: docsWorkspace.config, + context, + absolutePathToFernFolder: docsWorkspace.absoluteFilePath, + absoluteFilepathToDocsConfig: docsWorkspace.absoluteFilepathToDocsConfig + }); + + if (parsedDocsConfig.navigation.type !== "untabbed") { + throw new Error("Expected untabbed navigation"); + } + const apiSection = parsedDocsConfig.navigation.items[0]; + if (apiSection?.type !== "apiSection") { + throw new Error("Expected apiSection"); + } + + const result = await loadAPIWorkspace({ + absolutePathToWorkspace: fernDirectory, + context, + cliVersion: "0.0.0", + workspaceName: undefined + }); + if (!result.didSucceed) { + throw new Error("API workspace failed to load"); + } + const apiWorkspace = await result.workspace.toFernWorkspace({ context }); + + const graphqlResult = await new GraphQLConverter({ + context, + filePath: [resolve(AbsoluteFilePath.of(__dirname), `${fixture}/definition/schema.graphql`)], + namespace, + examples: [] + }).convert(); + + const ir = generateIntermediateRepresentation({ + workspace: apiWorkspace, + audiences: { type: "all" }, + generationLanguage: undefined, + keywords: undefined, + smartCasing: false, + exampleGeneration: { disabled: false }, + readme: undefined, + version: undefined, + packageName: undefined, + context, + sourceResolver: new SourceResolverImpl(context, apiWorkspace) + }); + + const apiDefinition = convertIrToApiDefinition({ + ir, + apiDefinitionId, + context, + graphqlOperations: graphqlResult.graphqlOperations, + graphqlTypes: graphqlResult.types + }); + + return new ApiReferenceNodeConverter( + apiSection, + apiDefinition, + FernNavigation.V1.SlugGenerator.init("/base/path"), + docsWorkspace, + context, + new Map(), + new Map(), + new Map(), + NodeIdGenerator.init(), + new Map(), + apiWorkspace, + undefined, + undefined, + undefined, + undefined, + graphqlResult.typeCategories + ).get(); +} + +/** The single section holding every kind, or undefined when the schema declares no types. */ +function typesRoot(node: FernNavigation.V1.ApiReferenceNode): FernNavigation.V1.ApiPackageNode | undefined { + return node.children.find( + (child): child is FernNavigation.V1.ApiPackageNode => child.type === "apiPackage" && child.title === "Types" + ); +} + +function typeSection( + node: FernNavigation.V1.ApiReferenceNode, + title: string +): FernNavigation.V1.ApiPackageNode | undefined { + return (typesRoot(node)?.children ?? []).find( + (child): child is FernNavigation.V1.ApiPackageNode => child.type === "apiPackage" && child.title === title + ); +} + +function graphqlTypeChildren(section: FernNavigation.V1.ApiPackageNode | undefined) { + return (section?.children ?? []).filter( + (child): child is Extract => + child.type === "graphqlType" + ); +} + +describe("GraphQL type navigation", () => { + it("emits one page per named type, grouped by GraphQL kind", async () => { + const node = await convertFixture(); + + const objects = typeSection(node, "Objects"); + expect(graphqlTypeChildren(objects).map((child) => child.title)).toEqual(["Collection", "Product"]); + expect(graphqlTypeChildren(objects).map((child) => child.slug)).toEqual([ + "base/path/graph-ql-api-reference/types/objects/collection", + "base/path/graph-ql-api-reference/types/objects/product" + ]); + + expect(graphqlTypeChildren(typeSection(node, "Inputs")).map((child) => child.slug)).toEqual([ + "base/path/graph-ql-api-reference/types/inputs/product-input" + ]); + expect(graphqlTypeChildren(typeSection(node, "Interfaces")).map((child) => child.slug)).toEqual([ + "base/path/graph-ql-api-reference/types/interfaces/node" + ]); + expect(graphqlTypeChildren(typeSection(node, "Enums")).map((child) => child.slug)).toEqual([ + "base/path/graph-ql-api-reference/types/enums/product-sort-keys" + ]); + expect(graphqlTypeChildren(typeSection(node, "Unions")).map((child) => child.slug)).toEqual([ + "base/path/graph-ql-api-reference/types/unions/search-result" + ]); + expect(graphqlTypeChildren(typeSection(node, "Scalars")).map((child) => child.slug)).toEqual([ + "base/path/graph-ql-api-reference/types/scalars/date-time" + ]); + }); + + it("records the category on every node and never emits a Query or Mutation page", async () => { + const node = await convertFixture(); + + const categoriesByTitle = new Map(); + for (const { category, title } of [ + { category: "object", title: "Objects" }, + { category: "input", title: "Inputs" }, + { category: "interface", title: "Interfaces" }, + { category: "enum", title: "Enums" }, + { category: "union", title: "Unions" }, + { category: "scalar", title: "Scalars" } + ] as const) { + for (const child of graphqlTypeChildren(typeSection(node, title))) { + expect(child.typeCategory).toBe(category); + categoriesByTitle.set(child.title, child.typeCategory); + } + } + + expect(categoriesByTitle.has("Query")).toBe(false); + expect(categoriesByTitle.has("Mutation")).toBe(false); + expect(categoriesByTitle.size).toBe(7); + }); + + it("does not create a section for a kind the schema does not declare", async () => { + const node = await convertFixture(); + + // Every kind section that exists has at least one page. + for (const child of typesRoot(node)?.children ?? []) { + if (child.type === "apiPackage") { + expect(graphqlTypeChildren(child).length).toBeGreaterThan(0); + } + } + + expect(typeSection(node, "Objects")).toBeDefined(); + expect(typeSection(node, "Scalars")).toBeDefined(); + }); + + it("nests every kind under a single Types section", async () => { + const node = await convertFixture(); + + const root = typesRoot(node); + expect(root?.slug).toBe("base/path/graph-ql-api-reference/types"); + // The kinds are sections of that one node, not siblings of Queries/Mutations. + expect( + (root?.children ?? []) + .filter((child): child is FernNavigation.V1.ApiPackageNode => child.type === "apiPackage") + .map((child) => child.title) + ).toEqual(["Objects", "Inputs", "Enums", "Scalars", "Interfaces", "Unions"]); + expect(node.children.filter((child) => child.type === "apiPackage" && child.title === "Objects")).toEqual([]); + }); + + it("emits the section at the API root when the API also has subpackages", async () => { + // An OpenAPI spec alongside the schema turns its tags into subpackages. Types belong to + // the schema, so they must not be nested under whichever tag is converted first. + const node = await convertFixture({ fixture: FIXTURE_WITH_SUBPACKAGES }); + + const subpackages = node.children.filter( + (child): child is FernNavigation.V1.ApiPackageNode => child.type === "apiPackage" && child.title !== "Types" + ); + expect(subpackages.length).toBeGreaterThan(0); + for (const subpackage of subpackages) { + expect( + subpackage.children.some((child) => child.type === "apiPackage" && child.slug.includes("/types/")) + ).toBe(false); + } + + expect(typesRoot(node)?.slug).toBe("base/path/graph-ql-api-reference/types"); + expect(graphqlTypeChildren(typeSection(node, "Objects")).map((child) => child.slug)).toEqual([ + "base/path/graph-ql-api-reference/types/objects/collection", + "base/path/graph-ql-api-reference/types/objects/product" + ]); + }); + + it("keeps namespaced type ids and slugs distinct so same-named types do not collide", async () => { + const node = await convertFixture({ namespace: "storefront" }); + + const objects = graphqlTypeChildren(typeSection(node, "Objects")); + expect(objects.map((child) => child.typeId)).toEqual([ + FdrAPI.TypeId("storefront_Collection"), + FdrAPI.TypeId("storefront_Product") + ]); + expect(objects.map((child) => child.slug)).toEqual([ + "base/path/graph-ql-api-reference/types/objects/storefront-collection", + "base/path/graph-ql-api-reference/types/objects/storefront-product" + ]); + }); +}); diff --git a/packages/cli/docs-resolver/vitest.config.ts b/packages/cli/docs-resolver/vitest.config.ts index 8cdbd6488780..02eb273ab629 100644 --- a/packages/cli/docs-resolver/vitest.config.ts +++ b/packages/cli/docs-resolver/vitest.config.ts @@ -14,7 +14,8 @@ const include = [ "src/__test__/sidebar-title.test.ts", "src/__test__/product-landing-page.test.ts", "src/__test__/versioned-root-landing-page.test.ts", - "src/__test__/library-hardfail.test.ts" + "src/__test__/library-hardfail.test.ts", + "src/__test__/graphql-type-navigation.test.ts" ]; export default defineConfig({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 799951499f36..60149c1af4c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,7 +587,7 @@ overrides: minimatch: '>=10.2.3' qs: 6.15.2 url-join: ^4.0.1 - '@fern-api/fdr-sdk': 1.2.96-8e5f0d55dd + '@fern-api/fdr-sdk': 1.2.102-07ddc01b9b form-data: ^4.0.6 '@fern-api/ui-core-utils': 0.145.12-b50d999d1 vite: ^7.3.5 @@ -3914,9 +3914,12 @@ importers: packages/cli/api-importers/graphql: dependencies: + '@fern-api/core-utils': + specifier: workspace:* + version: link:../../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -4291,8 +4294,8 @@ importers: specifier: workspace:* version: link:../../configuration '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/ir-generator': specifier: workspace:* version: link:../../generation/ir-generator @@ -4418,8 +4421,8 @@ importers: specifier: workspace:* version: link:../yaml/docs-validator '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-formatter': specifier: workspace:* version: link:../fern-definition/formatter @@ -4876,8 +4879,8 @@ importers: specifier: workspace:* version: link:../yaml/docs-validator '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../fern-definition/schema @@ -5081,8 +5084,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../fern-definition/schema @@ -5118,8 +5121,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5197,8 +5200,8 @@ importers: specifier: workspace:* version: link:../../configuration '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5237,8 +5240,8 @@ importers: specifier: workspace:* version: link:../commons '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5277,8 +5280,8 @@ importers: specifier: workspace:* version: link:../commons '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5344,8 +5347,8 @@ importers: packages/cli/docs-markdown-utils: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5435,8 +5438,8 @@ importers: specifier: workspace:* version: link:../docs-resolver '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5550,8 +5553,8 @@ importers: specifier: workspace:* version: link:../docs-markdown-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5653,8 +5656,8 @@ importers: specifier: workspace:* version: link:../configuration '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -6364,8 +6367,8 @@ importers: specifier: 'catalog:' version: 0.0.6-2ee1b7e28 '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../../commons/fs-utils @@ -6595,8 +6598,8 @@ importers: specifier: workspace:* version: link:../generation/local-generation/docker-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -6844,8 +6847,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -7040,8 +7043,8 @@ importers: specifier: workspace:* version: link:../../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/ir-sdk': specifier: workspace:* version: link:../../../ir-sdk @@ -7125,8 +7128,8 @@ importers: specifier: workspace:* version: link:../../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../../fern-definition/schema @@ -7368,8 +7371,8 @@ importers: specifier: workspace:* version: link:../../docs-resolver '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../../fern-definition/schema @@ -8013,8 +8016,8 @@ importers: packages/core: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.96-8e5f0d55dd - version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.102-07ddc01b9b + version: 1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/venus-api-sdk': specifier: 'catalog:' version: 5.0.0 @@ -9567,8 +9570,8 @@ packages: resolution: {integrity: sha512-3qhAAuc4ZJWLaFtyZzaYXfF9OQ5iviNrvLDXtjKScKUNS134fR3v3c3xedCidTq5KedapuBECziUaOmmd6KXVA==} engines: {node: '>=18.0.0'} - '@fern-api/fdr-sdk@1.2.96-8e5f0d55dd': - resolution: {integrity: sha512-g4kfEIBohQJBCwYnTqQfY/Il3TY5tE4je2Us10UJAjFl8VcwQkGU/sq4kRbkx+BiHbZI/NP2USlWMBXjK7FtJA==} + '@fern-api/fdr-sdk@1.2.102-07ddc01b9b': + resolution: {integrity: sha512-Ss6AzCpzR/aVuMRBpwoHc9OYTMq907Qj/iQMYjBkRqgbGRoNhyjiQeZZy54nHVW0s7Dpo+YAH9m4N2BQsSBxxA==} '@fern-api/generator-cli@0.9.53': resolution: {integrity: sha512-737p5KbB8DZQFhU3JhW6hvI2If2O8ySDUb4bWXB1OzFT/dhAqSxhCDpAp9vrQH53ldtEEzAYqR44yAxw/0wUgA==} @@ -16532,7 +16535,7 @@ snapshots: '@fern-api/fai-sdk@0.0.6-2ee1b7e28': {} - '@fern-api/fdr-sdk@1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3)': + '@fern-api/fdr-sdk@1.2.102-07ddc01b9b(@opentelemetry/api@1.9.1)(typescript@5.9.3)': dependencies: '@fern-api/ui-core-utils': 0.145.12-b50d999d1 '@orpc/client': 1.13.9(@opentelemetry/api@1.9.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1f437b0c0257..48ad9ad7f90a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,7 +61,7 @@ catalog: "@bufbuild/protobuf": ^2.2.5 "@bufbuild/protoplugin": 2.2.5 "@fern-api/fai-sdk": 0.0.6-2ee1b7e28 - "@fern-api/fdr-sdk": 1.2.96-8e5f0d55dd + "@fern-api/fdr-sdk": 1.2.102-07ddc01b9b "@fern-api/generator-cli": 0.9.53 "@fern-api/ui-core-utils": 0.129.4-b6c699ad2 "@fern-api/venus-api-sdk": 5.0.0 @@ -263,7 +263,7 @@ overrides: minimatch: ">=10.2.3" qs: 6.15.2 url-join: ^4.0.1 - "@fern-api/fdr-sdk": 1.2.96-8e5f0d55dd + "@fern-api/fdr-sdk": 1.2.102-07ddc01b9b form-data: ^4.0.6 "@fern-api/ui-core-utils": 0.145.12-b50d999d1 vite: ^7.3.5