diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 23e32a7590c..aa3715047ac 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -30,15 +30,16 @@ "node": ">=20.0.0" }, "dependencies": { - "@alloy-js/core": "^0.11.0", - "@alloy-js/typescript": "^0.11.0", + "@alloy-js/core": "^0.22.0", + "@alloy-js/graphql": "^0.1.0", + "@alloy-js/typescript": "^0.22.0", "change-case": "^5.4.4", "graphql": "^16.9.0" }, "scripts": { "clean": "rimraf ./dist ./temp", - "build": "tsc -p .", - "watch": "tsc --watch", + "build": "alloy build", + "watch": "alloy build --watch", "test": "vitest run", "test:watch": "vitest -w", "lint": "eslint . --max-warnings=0", @@ -56,6 +57,8 @@ "@typespec/mutator-framework": "workspace:~" }, "devDependencies": { + "@alloy-js/cli": "^0.22.0", + "@alloy-js/rollup-plugin": "^0.1.0", "@types/node": "~22.13.13", "@typespec/compiler": "workspace:~", "@typespec/emitter-framework": "workspace:~", diff --git a/packages/graphql/src/components/fields/field.tsx b/packages/graphql/src/components/fields/field.tsx new file mode 100644 index 00000000000..16718f0597a --- /dev/null +++ b/packages/graphql/src/components/fields/field.tsx @@ -0,0 +1,62 @@ +import { type ModelProperty, getDoc, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { isNullable, hasNullableElements } from "../../lib/nullable.js"; +import { GraphQLTypeExpression } from "./type-expression.js"; + +export interface FieldProps { + /** The model property to render as a field */ + property: ModelProperty; + /** Whether this field is in an input type context */ + isInput: boolean; +} + +export function Field(props: FieldProps) { + const { program } = useTsp(); + + const doc = getDoc(program, props.property); + const deprecation = getDeprecationDetails(program, props.property); + + return ( + + {(typeInfo) => { + if (props.isInput) { + return ( + + {typeInfo.isList ? ( + + ) : undefined} + + ); + } + + return ( + + {typeInfo.isList ? ( + + ) : undefined} + + ); + }} + + ); +} diff --git a/packages/graphql/src/components/fields/index.ts b/packages/graphql/src/components/fields/index.ts new file mode 100644 index 00000000000..8eb36ee9c42 --- /dev/null +++ b/packages/graphql/src/components/fields/index.ts @@ -0,0 +1,7 @@ +export { Field, type FieldProps } from "./field.js"; +export { OperationField, type OperationFieldProps } from "./operation-field.js"; +export { + GraphQLTypeExpression, + type GraphQLTypeExpressionProps, + type GraphQLTypeInfo, +} from "./type-expression.js"; diff --git a/packages/graphql/src/components/fields/operation-field.tsx b/packages/graphql/src/components/fields/operation-field.tsx new file mode 100644 index 00000000000..960b4957512 --- /dev/null +++ b/packages/graphql/src/components/fields/operation-field.tsx @@ -0,0 +1,80 @@ +import { type Operation, getDoc, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { isNullable, hasNullableElements } from "../../lib/nullable.js"; +import { GraphQLTypeExpression } from "./type-expression.js"; + +export interface OperationFieldProps { + /** The operation to render as a field */ + operation: Operation; +} + +/** + * Renders an operation as a field with arguments, used for @operationFields. + */ +export function OperationField(props: OperationFieldProps) { + const { program } = useTsp(); + const params = Array.from(props.operation.parameters.properties.values()); + const doc = getDoc(program, props.operation); + const deprecation = getDeprecationDetails(program, props.operation); + + return ( + + {(returnTypeInfo) => ( + + {returnTypeInfo.isList ? ( + + ) : undefined} + {params.map((param) => ( + + {(paramTypeInfo) => ( + + {paramTypeInfo.isList ? ( + + ) : undefined} + + )} + + ))} + + )} + + ); +} diff --git a/packages/graphql/src/components/fields/type-expression.tsx b/packages/graphql/src/components/fields/type-expression.tsx new file mode 100644 index 00000000000..89273e49b9f --- /dev/null +++ b/packages/graphql/src/components/fields/type-expression.tsx @@ -0,0 +1,172 @@ +import { + type Type, + type Scalar, + type ModelProperty, + getEncode, + isUnknownType, +} from "@typespec/compiler"; +import { type Children } from "@alloy-js/core"; +import { useTsp } from "@typespec/emitter-framework"; +import { useGraphQLSchema } from "../../context/index.js"; +import { isNullable } from "../../lib/nullable.js"; +import { unwrapNullableUnion, getUnionName } from "../../lib/type-utils.js"; +import { getGraphQLBuiltinName, getScalarMapping } from "../../lib/scalar-mappings.js"; + +/** + * Information about a resolved GraphQL type + */ +export interface GraphQLTypeInfo { + /** The base type name (without wrappers) */ + typeName: string; + /** Whether this is a list type */ + isList: boolean; + /** Whether the field itself is non-null */ + isNonNull: boolean; + /** Whether list items are non-null (only meaningful if isList is true) */ + itemNonNull: boolean; +} + +export interface GraphQLTypeExpressionProps { + type: Type; + isOptional: boolean; + /** Whether this type is in an input position (operation parameter or input model field) */ + isInput: boolean; + /** Whether this type was marked nullable (from property-level tracking) */ + isNullable?: boolean; + /** Whether this property's array elements were originally T | null (from property-level tracking) */ + hasNullableElements?: boolean; + /** The property or parameter that contains the type (for @encode checking) */ + targetType?: Type; + children: (typeInfo: GraphQLTypeInfo) => Children; +} + +export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) { + const { $, program } = useTsp(); + const { modelVariants } = useGraphQLSchema(); + + const nullable = props.isNullable || isNullable(props.type); + + // Input fields are non-null unless nullable; optionality is expressed via + // default values. Output fields are non-null unless optional or nullable. + const isNonNull = nullable ? false : props.isInput || !props.isOptional; + + // Unwrap T | null unions the mutation engine didn't process (e.g., array + // elements, operation parameters that arrive here still wrapped). + if ($.union.is(props.type)) { + const innerType = unwrapNullableUnion(props.type); + if (innerType) { + return ( + + {(innerInfo) => + props.children({ + ...innerInfo, + isNonNull: false, + }) + } + + ); + } + } + + if ($.array.is(props.type)) { + const elementType = $.array.getElementType(props.type); + // Element nullability: from mutation engine property-level tracking, from the + // element type's own state map, or from an inline T | null union still present. + const elementIsNullable = + props.hasNullableElements || + isNullable(elementType) || + ($.union.is(elementType) && unwrapNullableUnion(elementType) !== undefined); + + return ( + + {(elementInfo) => + props.children({ + typeName: elementInfo.typeName, + isList: true, + isNonNull, + itemNonNull: !elementIsNullable, + }) + } + + ); + } + + const typeName = resolveBaseTypeName(); + + return props.children({ + typeName, + isList: false, + isNonNull, + itemNonNull: false, + }); + + function resolveBaseTypeName(): string { + const type = props.type; + + if (isUnknownType(type)) { + return "Unknown"; + } + + if ($.scalar.is(type)) { + const builtinName = getGraphQLBuiltinName(program, type); + if (builtinName) return builtinName; + + // Std scalars with encoding-specific mappings (e.g., bytes + base64 -> Bytes) + if (program.checker.isStdType(type)) { + if ( + props.targetType && + ($.scalar.is(props.targetType) || props.targetType.kind === "ModelProperty") + ) { + const encodeData = getEncode( + program, + props.targetType as Scalar | ModelProperty, + ); + const mapping = getScalarMapping(program, type, encodeData?.encoding); + if (mapping) return mapping.graphqlName; + } + + const mapping = getScalarMapping(program, type); + if (mapping) return mapping.graphqlName; + } + + return type.name; + } + + if ($.model.is(type)) { + // Both input and output variants share the same source model identity, + // so we use name-based lookup to determine if both variants exist. + const hasOutputVariant = modelVariants.outputModels.has(type.name); + const hasInputVariant = modelVariants.inputModels.has(type.name); + + if (props.isInput && hasOutputVariant && hasInputVariant) { + return `${type.name}Input`; + } + return type.name; + } + + if ($.enum.is(type)) { + return type.name; + } + + if ($.union.is(type)) { + return getUnionName(type, program); + } + + throw new Error( + `Unexpected type kind "${type.kind}" in resolveBaseTypeName. ` + + `This is a bug in the GraphQL emitter.`, + ); + } +} diff --git a/packages/graphql/src/context/graphql-schema-context.tsx b/packages/graphql/src/context/graphql-schema-context.tsx new file mode 100644 index 00000000000..db817783a95 --- /dev/null +++ b/packages/graphql/src/context/graphql-schema-context.tsx @@ -0,0 +1,99 @@ +import { type ComponentContext, createNamedContext, useContext } from "@alloy-js/core"; +import { + type Model, + type Enum, + type IntrinsicType, + type Scalar, + type Union, + type Operation, +} from "@typespec/compiler"; + +/** + * Classified types separated by category for schema generation + */ +export interface ClassifiedTypes { + /** Interface types marked with @Interface */ + interfaces: Model[]; + /** Models used as output types (return values) */ + outputModels: Model[]; + /** Models used as input types (parameters) */ + inputModels: Model[]; + /** Enum types */ + enums: Enum[]; + /** Custom scalar types */ + scalars: Scalar[]; + /** Scalar variants for encoded scalars (e.g., bytes + base64 → Bytes) */ + scalarVariants: ScalarVariant[]; + /** Union types */ + unions: Union[]; + /** Query operations */ + queries: Operation[]; + /** Mutation operations */ + mutations: Operation[]; + /** Subscription operations */ + subscriptions: Operation[]; +} + +/** + * Model variant lookups for quick checking whether output and/or input variants exist. + * Used to determine when to append "Input" suffix during type resolution. + */ +export interface ModelVariants { + /** Output model variants indexed by name */ + outputModels: Map; + /** Input model variants indexed by name */ + inputModels: Map; +} + +/** + * Scalar variant information for encoded scalars. + * When a scalar has @encode, we emit it as a different GraphQL scalar (e.g., bytes + base64 → Bytes) + */ +export interface ScalarVariant { + /** The original TypeSpec scalar type, or IntrinsicType for `unknown` */ + sourceScalar: Scalar | IntrinsicType; + /** The encoding used (e.g., "base64", "rfc3339") */ + encoding: string; + /** The GraphQL scalar name to emit (e.g., "Bytes", "UTCDateTime") */ + graphqlName: string; + /** Optional specification URL for @specifiedBy directive */ + specificationUrl?: string; +} + +/** + * Context value containing GraphQL-specific schema information. + * + * For access to the TypeSpec program and typekit, use `useTsp()` from + * `@typespec/emitter-framework` instead. + */ +export interface GraphQLSchemaContextValue { + /** Classified types for schema generation */ + classifiedTypes: ClassifiedTypes; + /** Model variant lookups for input/output type resolution */ + modelVariants: ModelVariants; + /** Scalar specification URLs for @specifiedBy directives */ + scalarSpecifications: Map; +} + +/** + * Context provider for GraphQL schema generation + */ +export const GraphQLSchemaContext: ComponentContext = + createNamedContext("GraphQLSchema"); + +/** + * Hook to access GraphQL schema context + * @returns The GraphQL schema context value + * @throws Error if used outside of GraphQLSchemaContext.Provider + */ +export function useGraphQLSchema(): GraphQLSchemaContextValue { + const context = useContext(GraphQLSchemaContext); + + if (!context) { + throw new Error( + "useGraphQLSchema must be used within GraphQLSchemaContext.Provider." + ); + } + + return context; +} diff --git a/packages/graphql/src/context/index.ts b/packages/graphql/src/context/index.ts new file mode 100644 index 00000000000..b353281709c --- /dev/null +++ b/packages/graphql/src/context/index.ts @@ -0,0 +1,8 @@ +export { + GraphQLSchemaContext, + useGraphQLSchema, + type GraphQLSchemaContextValue, + type ClassifiedTypes, + type ModelVariants, + type ScalarVariant, +} from "./graphql-schema-context.js"; diff --git a/packages/graphql/src/lib/scalar-mappings.ts b/packages/graphql/src/lib/scalar-mappings.ts index 20cdf846536..5956fce34f4 100644 --- a/packages/graphql/src/lib/scalar-mappings.ts +++ b/packages/graphql/src/lib/scalar-mappings.ts @@ -170,6 +170,21 @@ const TSP_SCALARS_TO_GQL_BUILTINS: IntrinsicScalarName[] = [ "string", "boolean", "int32", "float32", "float64", ]; +/** + * Map a TypeSpec std scalar to its GraphQL built-in scalar name, if any. + * + * Returns undefined for non-builtin scalars. Uses checker.isStdType + * (name + namespace) which works on both original and mutated scalars. + */ +export function getGraphQLBuiltinName(program: Program, scalar: Scalar): string | undefined { + if (program.checker.isStdType(scalar, "string")) return "String"; + if (program.checker.isStdType(scalar, "boolean")) return "Boolean"; + if (program.checker.isStdType(scalar, "int32")) return "Int"; + if (program.checker.isStdType(scalar, "float32")) return "Float"; + if (program.checker.isStdType(scalar, "float64")) return "Float"; + return undefined; +} + /** * Get the GraphQL scalar mapping for a scalar via its standard library ancestor. * diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json index ad68b784463..d2f18d1ce33 100644 --- a/packages/graphql/tsconfig.json +++ b/packages/graphql/tsconfig.json @@ -1,10 +1,18 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "useDefineForClassFields": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "jsx": "preserve", + "jsxImportSource": "@alloy-js/core", + "emitDeclarationOnly": true, "rootDir": ".", - "outDir": "dist", - "verbatimModuleSyntax": true + "outDir": "dist" }, - "include": ["src", "test"] + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/graphql/vitest.config.ts b/packages/graphql/vitest.config.ts index 63cad767f57..b45007f3475 100644 --- a/packages/graphql/vitest.config.ts +++ b/packages/graphql/vitest.config.ts @@ -1,4 +1,14 @@ +import alloyPlugin from "@alloy-js/rollup-plugin"; import { defineConfig, mergeConfig } from "vitest/config"; import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; -export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); +export default mergeConfig( + defaultTypeSpecVitestConfig, + defineConfig({ + esbuild: { + jsx: "preserve", + sourcemap: "both", + }, + plugins: [alloyPlugin()], + }), +);