Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/graphql/api-extractor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../api-extractor.base.json"
}
1 change: 1 addition & 0 deletions packages/graphql/lib/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ import "./interface.tsp";
import "./operation-fields.tsp";
import "./operation-kind.tsp";
import "./schema.tsp";
import "./specified-by.tsp";
19 changes: 19 additions & 0 deletions packages/graphql/lib/specified-by.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import "../dist/src/lib/specified-by.js";

using TypeSpec.Reflection;

namespace TypeSpec.GraphQL;

/**
* Provide a specification URL for a custom GraphQL scalar type.
* This maps to the `@specifiedBy` directive in the emitted GraphQL schema.
*
* @param url URL to the scalar type specification
* @example
*
* ```typespec
* @specifiedBy("https://scalars.graphql.org/jakobmerrild/long.html")
* scalar Long extends int64;
* ```
*/
extern dec specifiedBy(target: Scalar, url: valueof string);
Comment thread
FionaBronwen marked this conversation as resolved.
Outdated
18 changes: 11 additions & 7 deletions packages/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"typespec"
],
"type": "module",
"tspMain": "lib/main.tsp",
"main": "dist/src/index.js",
"exports": {
".": {
Expand All @@ -30,14 +31,12 @@
}
},
"engines": {
"node": ">=18.0.0"
},
"graphql": {
"documents": "test/**/*.{js,ts}"
"node": ">=20.0.0"
},
"dependencies": {
"@alloy-js/core": "^0.11.0",
"@alloy-js/typescript": "^0.11.0",
"change-case": "^5.4.4",
"graphql": "^16.9.0"
},
"scripts": {
Expand All @@ -46,8 +45,8 @@
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest -w",
"lint": "eslint src/ test/ --report-unused-disable-directives --max-warnings=0",
"lint:fix": "eslint . --report-unused-disable-directives --fix"
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix"
},
"files": [
"lib/*.tsp",
Expand All @@ -56,11 +55,16 @@
],
"peerDependencies": {
"@typespec/compiler": "workspace:~",
"@typespec/emitter-framework": "workspace:~",
"@typespec/http": "workspace:~",
"@typespec/emitter-framework": "^0.5.0"
"@typespec/mutator-framework": "workspace:~"
},
"devDependencies": {
"@types/node": "~22.13.13",
"@typespec/compiler": "workspace:~",
"@typespec/emitter-framework": "workspace:~",
"@typespec/http": "workspace:~",
"@typespec/mutator-framework": "workspace:~",
"rimraf": "~6.0.1",
"source-map-support": "~0.5.21",
"typescript": "~5.8.2",
Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { $onEmit } from "./emitter.js";
export { $lib } from "./lib.js";
export { $decorators } from "./tsp-index.js";

export { createGraphQLMutationEngine } from "./mutation-engine/index.js";
14 changes: 14 additions & 0 deletions packages/graphql/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ export const libDef = {
default: paramMessage`Property \`${"property"}\` is incompatible with \`${"interface"}\`.`,
},
},
"unrecognized-union": {
severity: "error",
messages: {
default: "Unrecognized union construction. Union must be named, a return type, a model property, or an alias.",
},
},
"duplicate-union-variant": {
severity: "warning",
messages: {
default: paramMessage`Union variant type "${"type"}" appears multiple times after flattening nested unions. Duplicate removed.`,
},
},
},
emitter: {
options: EmitterOptionsSchema as JSONSchemaType<GraphQLEmitterOptions>,
Expand All @@ -149,6 +161,8 @@ export const libDef = {
compose: { description: "State for the @compose decorator." },
interface: { description: "State for the @Interface decorator." },
schema: { description: "State for the @schema decorator." },
specifiedBy: { description: "State for the @specifiedBy decorator." },
oneOf: { description: "State for tracking @oneOf input objects created from input unions." },
},
} as const;

Expand Down
8 changes: 1 addition & 7 deletions packages/graphql/src/lib/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { useStateMap, useStateSet } from "@typespec/compiler/utils";
import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js";
import type { Tagged } from "../types.d.ts";
import { propertiesEqual } from "./utils.js";
Comment thread
FionaBronwen marked this conversation as resolved.

// This will set the namespace for decorators implemented in this file
export const namespace = NAMESPACE;
Expand Down Expand Up @@ -75,13 +76,6 @@ function validateNoCircularImplementation(
return valid;
}

function propertiesEqual(prop1: ModelProperty, prop2: ModelProperty): boolean {
// TODO is there some canonical way to do this?
return (
prop1.name === prop2.name && prop1.type === prop2.type && prop1.optional === prop2.optional
);
}

function validateImplementsInterfaceProperties(
context: DecoratorContext,
modelProperties: Map<string, ModelProperty>,
Expand Down
22 changes: 22 additions & 0 deletions packages/graphql/src/lib/one-of.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Model, Program } from "@typespec/compiler";
import { useStateMap } from "@typespec/compiler/utils";
import { GraphQLKeys } from "../lib.js";

const [getOneOfState, setOneOfState] = useStateMap<Model, boolean>(GraphQLKeys.oneOf);
Comment thread
FionaBronwen marked this conversation as resolved.
Outdated

/**
* Check if a model has been marked as a @oneOf input object.
* These are synthetic models created by the union mutation when a union
* is used in input context — GraphQL unions are output-only, so input
* unions become @oneOf input objects.
*/
export function isOneOf(program: Program, model: Model): boolean {
return getOneOfState(program, model) === true;
}

/**
* Mark a model as a @oneOf input object.
*/
export function setOneOf(program: Program, model: Model): void {
setOneOfState(program, model, true);
}
3 changes: 0 additions & 3 deletions packages/graphql/src/lib/operation-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import {
type Operation,
type Program,
} from "@typespec/compiler";

// import { createTypeRelationChecker } from "../../../compiler/dist/src/core/type-relation-checker.js";

import { useStateMap } from "@typespec/compiler/utils";
import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js";
import { operationsEqual } from "./utils.js";
Expand Down
206 changes: 206 additions & 0 deletions packages/graphql/src/lib/scalar-mappings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { type Program, type Scalar } from "@typespec/compiler";
import { $, type Typekit } from "@typespec/compiler/typekit";

/**
* Represents a mapping from a TypeSpec standard library scalar to a GraphQL custom scalar.
*/
export interface ScalarMapping {
/** The GraphQL scalar name to emit */
graphqlName: string;
/** The base GraphQL type (String, Int, or Float) */
baseType: "String" | "Int" | "Float" | "Boolean" | "ID";
/** Optional URL to specification for @specifiedBy directive */
specificationUrl?: string;
}

/**
* Mapping table for TypeSpec standard library scalars to GraphQL custom scalars.
*
* Built-in scalars (string, boolean, int32, float64, etc.) are NOT included here —
* they map directly to GraphQL built-in types and are resolved at emit time.
* This table only covers scalars that need to become custom GraphQL scalar types.
*/
const SCALAR_MAPPINGS = {
// int64 → Long (String)
int64: {
default: {
graphqlName: "Long",
baseType: "String",
specificationUrl: "http://scalars.graphql.org/jakobmerrild/long.html",
},
},

// numeric → Numeric (String)
numeric: {
default: {
graphqlName: "Numeric",
baseType: "String",
},
},

// decimal, decimal128 → BigDecimal (String)
decimal: {
default: {
graphqlName: "BigDecimal",
baseType: "String",
specificationUrl: "https://scalars.graphql.org/chillicream/decimal.html",
},
},
decimal128: {
default: {
graphqlName: "BigDecimal",
baseType: "String",
specificationUrl: "https://scalars.graphql.org/chillicream/decimal.html",
},
},

// bytes — requires @encode to determine format; without encoding, no GraphQL mapping applies
bytes: {
base64: {
graphqlName: "Bytes",
baseType: "String",
specificationUrl: "https://datatracker.ietf.org/doc/html/rfc4648#section-4",
},
base64url: {
graphqlName: "BytesUrl",
baseType: "String",
specificationUrl: "https://datatracker.ietf.org/doc/html/rfc4648#section-5",
},
},

// utcDateTime — requires @encode to determine wire format; no default mapping without encoding
utcDateTime: {
rfc3339: {
graphqlName: "UTCDateTime",
baseType: "String",
specificationUrl: "https://scalars.graphql.org/chillicream/date-time.html",
},
rfc7231: {
graphqlName: "UTCDateTimeHuman",
baseType: "String",
specificationUrl: "https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1",
},
unixTimestamp: {
graphqlName: "UTCDateTimeUnix",
baseType: "Int",
},
},

// offsetDateTime — requires @encode to determine wire format; no default mapping without encoding
offsetDateTime: {
rfc3339: {
graphqlName: "OffsetDateTime",
baseType: "String",
specificationUrl: "https://scalars.graphql.org/chillicream/date-time.html",
},
rfc7231: {
graphqlName: "OffsetDateTimeHuman",
baseType: "String",
specificationUrl: "https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1",
},
unixTimestamp: {
graphqlName: "OffsetDateTimeUnix",
baseType: "Int",
},
},

// duration — requires @encode to determine wire format; no default mapping without encoding
duration: {
ISO8601: {
graphqlName: "Duration",
baseType: "String",
specificationUrl: "https://www.iso.org/standard/70907.html",
},
seconds: {
graphqlName: "DurationSeconds",
baseType: "Int", // Could be Float based on context, defaulting to Int
},
},

// plainDate → PlainDate (String)
plainDate: {
default: {
graphqlName: "PlainDate",
baseType: "String",
specificationUrl: "https://scalars.graphql.org/andimarek/local-date.html",
},
},

// plainTime → PlainTime (String)
plainTime: {
default: {
graphqlName: "PlainTime",
baseType: "String",
specificationUrl: "https://scalars.graphql.org/apollographql/localtime-v0.1.html",
},
},

// url → URL (String)
url: {
default: {
graphqlName: "URL",
baseType: "String",
specificationUrl: "https://url.spec.whatwg.org/",
},
},

} as const;

type MappedScalarName = keyof typeof SCALAR_MAPPINGS;

/**
* Check whether a scalar IS a standard library scalar (not just extends one).
* A std scalar's std base is itself. A user-defined scalar's std base is
* its ancestor (or null if it has no std ancestor).
*/
export function isStdScalar(tk: Typekit, scalar: Scalar): boolean {
return tk.scalar.getStdBase(scalar) === scalar;
}

/**
* Get the GraphQL custom scalar mapping for a scalar via its standard library ancestor.
*
* Uses `tk.scalar.getStdBase()` to find the std ancestor (e.g. `int64` for
* `scalar MyInt extends int64`), then looks up the mapping table by name.
* Returns undefined for built-in scalars (string, boolean, etc.)
* and scalars with no mapped ancestor.
*
* The caller (scalar mutation) uses `isStdScalar` to decide whether to
* rename with `mapping.graphqlName` or keep the user's name. The mapping
* is always useful for metadata like `@specifiedBy`.
*
* @param program The TypeSpec program
* @param scalar The scalar type to map
* @param encoding Optional encoding to use instead of checking @encode on the scalar
* @returns The scalar mapping or undefined if no mapping exists
*/
export function getScalarMapping(
program: Program,
scalar: Scalar,
encoding?: string,
): ScalarMapping | undefined {
const tk = $(program);

// getStdBase walks the baseScalar chain and returns the first ancestor
// in the TypeSpec namespace (identity-safe, not name-based).
const stdBase = tk.scalar.getStdBase(scalar);
if (!stdBase || !(stdBase.name in SCALAR_MAPPINGS)) {
return undefined;
}
Comment thread
FionaBronwen marked this conversation as resolved.

const mappingTable = SCALAR_MAPPINGS[stdBase.name as MappedScalarName];
Comment thread
FionaBronwen marked this conversation as resolved.

// Encoding is checked on the original scalar, not the ancestor.
const actualEncoding = encoding ?? tk.scalar.getEncoding(scalar)?.encoding;
if (actualEncoding) {
const encodingMapping = (mappingTable as Record<string, ScalarMapping>)[actualEncoding];
if (encodingMapping) {
return encodingMapping;
}
}

// Fall back to default mapping (not all mapping tables have a default)
return "default" in mappingTable
? (mappingTable as Record<string, ScalarMapping>).default
: undefined;
}
Loading