Skip to content
3 changes: 3 additions & 0 deletions packages/graphql/src/components/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export { EnumType, type EnumTypeProps } from "./enum-type.js";
export { ScalarType, type ScalarTypeProps } from "./scalar-type.js";
export { UnionType, type UnionTypeProps, type GraphQLUnion } from "./union-type.js";
export { InterfaceType, type InterfaceTypeProps } from "./interface-type.js";
export { ObjectType, type ObjectTypeProps } from "./object-type.js";
export { InputType, type InputTypeProps } from "./input-type.js";
26 changes: 26 additions & 0 deletions packages/graphql/src/components/types/input-type.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Model } from "@typespec/compiler";
import * as gql from "@alloy-js/graphql";
import { useTsp } from "@typespec/emitter-framework";
import { Field } from "../fields/index.js";

export interface InputTypeProps {
/** The input type to render */
type: Model;
}

/**
* Renders a GraphQL input type declaration.
*/
export function InputType(props: InputTypeProps) {
const { $ } = useTsp();
const doc = $.type.getDoc(props.type);
const properties = Array.from(props.type.properties.values());

return (
<gql.InputObjectType name={props.type.name} description={doc}>
{properties.map((prop) => (
<Field property={prop} isInput={true} />
))}
</gql.InputObjectType>
);
}
28 changes: 28 additions & 0 deletions packages/graphql/src/components/types/interface-type.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Model } from "@typespec/compiler";
import * as gql from "@alloy-js/graphql";
import { useTsp } from "@typespec/emitter-framework";
import { Field } from "../fields/index.js";

export interface InterfaceTypeProps {
/** The interface type to render */
type: Model;
}

/**
* Renders a GraphQL interface type declaration
*
* Interfaces are marked with @Interface decorator in TypeSpec
*/
export function InterfaceType(props: InterfaceTypeProps) {
const { $ } = useTsp();
const doc = $.type.getDoc(props.type);
const properties = Array.from(props.type.properties.values());

return (
<gql.InterfaceType name={props.type.name} description={doc}>
{properties.map((prop) => (
<Field property={prop} isInput={false} />
))}
</gql.InterfaceType>
);
}
44 changes: 44 additions & 0 deletions packages/graphql/src/components/types/object-type.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Model } from "@typespec/compiler";
import * as gql from "@alloy-js/graphql";
import { useTsp } from "@typespec/emitter-framework";
import { Field, OperationField } from "../fields/index.js";
import { getComposition } from "../../lib/interface.js";
import { getOperationFields } from "../../lib/operation-fields.js";

export interface ObjectTypeProps {
/** The object type to render */
type: Model;
}

/**
* Renders a GraphQL object type declaration
*
* Handles:
* - Regular fields from model properties
* - Interface implementations via @compose
* - Operation fields via @operationFields
*/
export function ObjectType(props: ObjectTypeProps) {
const { $, program } = useTsp();
const doc = $.type.getDoc(props.type);
const properties = Array.from(props.type.properties.values());
const implementations = getComposition(program, props.type);
const operationFields = getOperationFields(program, props.type);

const implementsRefs = implementations?.map((iface) => iface.name) || [];

return (
<gql.ObjectType
name={props.type.name}
description={doc}
interfaces={implementsRefs}
>
{properties.map((prop) => (
<Field property={prop} isInput={false} />
))}
{Array.from(operationFields).map((op) => (
<OperationField operation={op} />
))}
</gql.ObjectType>
);
}
102 changes: 102 additions & 0 deletions packages/graphql/test/components/input-type.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { t } from "@typespec/compiler/testing";
import { describe, expect, it, beforeEach } from "vitest";
import { InputType } from "../../src/components/types/index.js";
import { Tester } from "../test-host.js";
import { renderToSDL } from "./test-utils.js";

describe("InputType component", () => {
let tester: Awaited<ReturnType<typeof Tester.createInstance>>;
beforeEach(async () => {
tester = await Tester.createInstance();
});

it("renders a basic input type with fields", async () => {
const { CreateUser } = await tester.compile(
t.code`model ${t.model("CreateUser")} { name: string; email: string; }`,
);

const sdl = renderToSDL(tester.program, <InputType type={CreateUser} />);

expect(sdl).toMatchInlineSnapshot(`
"input CreateUser {
name: String!
email: String!
}

type Query {
_placeholder: Boolean
}"
`);
});

it("renders with doc comment description", async () => {
const { LoginInput } = await tester.compile(
t.code`
/** Credentials for login */
model ${t.model("LoginInput")} { username: string; password: string; }
`,
);

const sdl = renderToSDL(tester.program, <InputType type={LoginInput} />);

expect(sdl).toMatchInlineSnapshot(`
""""Credentials for login"""
input LoginInput {
username: String!
password: String!
}

type Query {
_placeholder: Boolean
}"
`);
});

it("renders optional fields as nullable", async () => {
// In GraphQL, optional fields without defaults are nullable (per spec:
// "nullability directly determines whether a field is required").
// This also allows circular references in input types (e.g., Author.friend?: Author).
const { UpdateUser } = await tester.compile(
t.code`model ${t.model("UpdateUser")} { name?: string; bio?: string; }`,
);

const sdl = renderToSDL(tester.program, <InputType type={UpdateUser} />);

expect(sdl).toMatchInlineSnapshot(`
"input UpdateUser {
name: String
bio: String
}

type Query {
_placeholder: Boolean
}"
`);
});

it("renders array fields as list types", async () => {
const { TagInput } = await tester.compile(
t.code`model ${t.model("TagInput")} { values: string[]; }`,
);

const sdl = renderToSDL(tester.program, <InputType type={TagInput} />);

expect(sdl).toMatchInlineSnapshot(`
"input TagInput {
values: [String!]!
}

type Query {
_placeholder: Boolean
}"
`);
});

it("throws error for empty model (GraphQL requires at least one field)", async () => {
const { Empty } = await tester.compile(t.code`model ${t.model("Empty")} {}`);

expect(() => {
renderToSDL(tester.program, <InputType type={Empty} />);
}).toThrow(/must define fields/);
});
});
117 changes: 117 additions & 0 deletions packages/graphql/test/components/interface-type.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { t } from "@typespec/compiler/testing";
import { describe, expect, it, beforeEach } from "vitest";
import { InterfaceType } from "../../src/components/types/index.js";
import { Tester } from "../test-host.js";
import { renderToSDL } from "./test-utils.js";

describe("InterfaceType component", () => {
let tester: Awaited<ReturnType<typeof Tester.createInstance>>;
beforeEach(async () => {
tester = await Tester.createInstance();
});

it("renders a basic interface with fields", async () => {
const { Node } = await tester.compile(
t.code`
@Interface
model ${t.model("Node")} { id: string; }
`,
);

const sdl = renderToSDL(tester.program, <InterfaceType type={Node} />);

expect(sdl).toMatchInlineSnapshot(`
"interface Node {
id: String!
}

type Query {
_placeholder: Boolean
}"
`);
});

it("renders with doc comment description", async () => {
const { Entity } = await tester.compile(
t.code`
/** A base entity */
@Interface
model ${t.model("Entity")} { id: string; }
`,
);

const sdl = renderToSDL(tester.program, <InterfaceType type={Entity} />);

expect(sdl).toMatchInlineSnapshot(`
""""A base entity"""
interface Entity {
id: String!
}

type Query {
_placeholder: Boolean
}"
`);
});

it("renders multiple fields with correct types", async () => {
const { Timestamped } = await tester.compile(
t.code`
@Interface
model ${t.model("Timestamped")} {
createdAt: string;
updatedAt: string;
version: int32;
}
`,
);

const sdl = renderToSDL(tester.program, <InterfaceType type={Timestamped} />);

expect(sdl).toMatchInlineSnapshot(`
"interface Timestamped {
createdAt: String!
updatedAt: String!
version: Int!
}

type Query {
_placeholder: Boolean
}"
`);
});

it("renders optional fields as nullable", async () => {
const { Described } = await tester.compile(
t.code`
@Interface
model ${t.model("Described")} { description?: string; }
`,
);

const sdl = renderToSDL(tester.program, <InterfaceType type={Described} />);

expect(sdl).toMatchInlineSnapshot(`
"interface Described {
description: String
}

type Query {
_placeholder: Boolean
}"
`);
});

it("throws error for empty interface (GraphQL requires at least one field)", async () => {
const { Empty } = await tester.compile(
t.code`
@Interface
model ${t.model("Empty")} {}
`,
);

expect(() => {
renderToSDL(tester.program, <InterfaceType type={Empty} />);
}).toThrow(/must define fields/);
});
});
Loading