Skip to content

Commit 2074f04

Browse files
committed
Add Object, Interface, and Input type components with tests
Add field-bearing type components that use the Field infrastructure (already present from the parent branch): - ObjectType: renders object types with fields, @compose interfaces, and @operationFields support - InterfaceType: renders interface type definitions with fields - InputType: renders input types with automatic Input suffix when a model appears in both input and output positions 17 new component tests covering: basic field rendering, doc comments, optional/nullable fields, array/list types, deprecated fields, interface implementation via @compose, and Input suffix logic.
1 parent c288111 commit 2074f04

7 files changed

Lines changed: 399 additions & 0 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export { ScalarType, type ScalarTypeProps } from "./scalar-type.js";
22
export { EnumType, type EnumTypeProps } from "./enum-type.js";
33
export { UnionType, type UnionTypeProps } from "./union-type.js";
4+
export { InterfaceType, type InterfaceTypeProps } from "./interface-type.js";
5+
export { ObjectType, type ObjectTypeProps } from "./object-type.js";
6+
export { InputType, type InputTypeProps } from "./input-type.js";
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { type Model, getDoc } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { useTsp } from "@typespec/emitter-framework";
4+
import { useGraphQLSchema } from "../../context/index.js";
5+
import { Field } from "../fields/index.js";
6+
7+
export interface InputTypeProps {
8+
/** The input type to render */
9+
type: Model;
10+
}
11+
12+
/**
13+
* Renders a GraphQL input type declaration
14+
*
15+
* Determines the correct input type name:
16+
* - If the model is also an output type, appends "Input"
17+
* - Otherwise, uses the name as-is
18+
*/
19+
export function InputType(props: InputTypeProps) {
20+
const { program } = useTsp();
21+
const { modelVariants } = useGraphQLSchema();
22+
const doc = getDoc(program, props.type);
23+
const properties = Array.from(props.type.properties.values());
24+
25+
// If there's an output variant with the same name, add Input suffix
26+
const hasOutputVariant = modelVariants.outputModels.has(props.type.name);
27+
const inputTypeName = hasOutputVariant ? `${props.type.name}Input` : props.type.name;
28+
29+
return (
30+
<gql.InputObjectType name={inputTypeName} description={doc}>
31+
{properties.map((prop) => (
32+
<Field property={prop} isInput={true} />
33+
))}
34+
</gql.InputObjectType>
35+
);
36+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { type Model, getDoc } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { useTsp } from "@typespec/emitter-framework";
4+
import { Field } from "../fields/index.js";
5+
6+
export interface InterfaceTypeProps {
7+
/** The interface type to render */
8+
type: Model;
9+
}
10+
11+
/**
12+
* Renders a GraphQL interface type declaration
13+
*
14+
* Interfaces are marked with @Interface decorator in TypeSpec
15+
*/
16+
export function InterfaceType(props: InterfaceTypeProps) {
17+
const { program } = useTsp();
18+
const doc = getDoc(program, props.type);
19+
const properties = Array.from(props.type.properties.values());
20+
21+
return (
22+
<gql.InterfaceType name={props.type.name} description={doc}>
23+
{properties.map((prop) => (
24+
<Field property={prop} isInput={false} />
25+
))}
26+
</gql.InterfaceType>
27+
);
28+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { type Model, getDoc } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { useTsp } from "@typespec/emitter-framework";
4+
import { Field, OperationField } from "../fields/index.js";
5+
import { getComposition } from "../../lib/interface.js";
6+
import { getOperationFields } from "../../lib/operation-fields.js";
7+
8+
export interface ObjectTypeProps {
9+
/** The object type to render */
10+
type: Model;
11+
}
12+
13+
/**
14+
* Renders a GraphQL object type declaration
15+
*
16+
* Handles:
17+
* - Regular fields from model properties
18+
* - Interface implementations via @compose
19+
* - Operation fields via @operationFields
20+
*/
21+
export function ObjectType(props: ObjectTypeProps) {
22+
const { program } = useTsp();
23+
const doc = getDoc(program, props.type);
24+
const properties = Array.from(props.type.properties.values());
25+
const implementations = getComposition(program, props.type);
26+
const operationFields = getOperationFields(program, props.type);
27+
28+
// Convert interface implementations to string references
29+
const implementsRefs = implementations?.map((iface) => iface.name) || [];
30+
31+
return (
32+
<gql.ObjectType
33+
name={props.type.name}
34+
description={doc}
35+
interfaces={implementsRefs}
36+
>
37+
{properties.map((prop) => (
38+
<Field property={prop} isInput={false} />
39+
))}
40+
{Array.from(operationFields).map((op) => (
41+
<OperationField operation={op} />
42+
))}
43+
</gql.ObjectType>
44+
);
45+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { type Model } from "@typespec/compiler";
2+
import { t } from "@typespec/compiler/testing";
3+
import { describe, expect, it, beforeEach } from "vitest";
4+
import { InputType } from "../../src/components/types/index.js";
5+
import { Tester } from "../test-host.js";
6+
import { renderComponentToSDL } from "./component-test-utils.js";
7+
8+
describe("InputType component", () => {
9+
let tester: Awaited<ReturnType<typeof Tester.createInstance>>;
10+
beforeEach(async () => {
11+
tester = await Tester.createInstance();
12+
});
13+
14+
it("renders a basic input type with fields", async () => {
15+
const { CreateUser } = await tester.compile(
16+
t.code`model ${t.model("CreateUser")} { name: string; email: string; }`,
17+
);
18+
19+
const sdl = renderComponentToSDL(tester.program, <InputType type={CreateUser} />);
20+
21+
expect(sdl).toContain("input CreateUser {");
22+
expect(sdl).toContain("name: String!");
23+
expect(sdl).toContain("email: String!");
24+
});
25+
26+
it("renders with doc comment description", async () => {
27+
const { LoginInput } = await tester.compile(
28+
t.code`
29+
/** Credentials for login */
30+
model ${t.model("LoginInput")} { username: string; password: string; }
31+
`,
32+
);
33+
34+
const sdl = renderComponentToSDL(tester.program, <InputType type={LoginInput} />);
35+
36+
expect(sdl).toContain("Credentials for login");
37+
expect(sdl).toContain("input LoginInput {");
38+
});
39+
40+
it("renders optional fields as non-null (GraphQL input convention)", async () => {
41+
// In GraphQL, input fields are always non-null; optionality is expressed
42+
// via default values, not nullability. Only `| null` makes them nullable.
43+
const { UpdateUser } = await tester.compile(
44+
t.code`model ${t.model("UpdateUser")} { name?: string; bio?: string; }`,
45+
);
46+
47+
const sdl = renderComponentToSDL(tester.program, <InputType type={UpdateUser} />);
48+
49+
expect(sdl).toContain("name: String!");
50+
expect(sdl).toContain("bio: String!");
51+
});
52+
53+
it("appends Input suffix when model has an output variant", async () => {
54+
const { Pet } = await tester.compile(
55+
t.code`model ${t.model("Pet")} { name: string; }`,
56+
);
57+
58+
const sdl = renderComponentToSDL(tester.program, <InputType type={Pet} />, {
59+
modelVariants: {
60+
outputModels: new Map([["Pet", Pet]]),
61+
inputModels: new Map([["Pet", Pet]]),
62+
},
63+
});
64+
65+
expect(sdl).toContain("input PetInput {");
66+
});
67+
68+
it("uses original name when no output variant exists", async () => {
69+
const { CreatePet } = await tester.compile(
70+
t.code`model ${t.model("CreatePet")} { name: string; }`,
71+
);
72+
73+
const sdl = renderComponentToSDL(tester.program, <InputType type={CreatePet} />);
74+
75+
expect(sdl).toContain("input CreatePet {");
76+
expect(sdl).not.toContain("CreatePetInput");
77+
});
78+
79+
it("renders array fields as list types", async () => {
80+
const { TagInput } = await tester.compile(
81+
t.code`model ${t.model("TagInput")} { values: string[]; }`,
82+
);
83+
84+
const sdl = renderComponentToSDL(tester.program, <InputType type={TagInput} />);
85+
86+
expect(sdl).toContain("values: [String!]!");
87+
});
88+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { type Model } from "@typespec/compiler";
2+
import { t } from "@typespec/compiler/testing";
3+
import { describe, expect, it, beforeEach } from "vitest";
4+
import { InterfaceType } from "../../src/components/types/index.js";
5+
import { Tester } from "../test-host.js";
6+
import { renderComponentToSDL } from "./component-test-utils.js";
7+
8+
describe("InterfaceType component", () => {
9+
let tester: Awaited<ReturnType<typeof Tester.createInstance>>;
10+
beforeEach(async () => {
11+
tester = await Tester.createInstance();
12+
});
13+
14+
it("renders a basic interface with fields", async () => {
15+
const { Node } = await tester.compile(
16+
t.code`
17+
@Interface
18+
model ${t.model("Node")} { id: string; }
19+
`,
20+
);
21+
22+
const sdl = renderComponentToSDL(tester.program, <InterfaceType type={Node} />);
23+
24+
expect(sdl).toContain("interface Node {");
25+
expect(sdl).toContain("id: String!");
26+
});
27+
28+
it("renders with doc comment description", async () => {
29+
const { Entity } = await tester.compile(
30+
t.code`
31+
/** A base entity */
32+
@Interface
33+
model ${t.model("Entity")} { id: string; }
34+
`,
35+
);
36+
37+
const sdl = renderComponentToSDL(tester.program, <InterfaceType type={Entity} />);
38+
39+
expect(sdl).toContain("A base entity");
40+
expect(sdl).toContain("interface Entity {");
41+
});
42+
43+
it("renders multiple fields with correct types", async () => {
44+
const { Timestamped } = await tester.compile(
45+
t.code`
46+
@Interface
47+
model ${t.model("Timestamped")} {
48+
createdAt: string;
49+
updatedAt: string;
50+
version: int32;
51+
}
52+
`,
53+
);
54+
55+
const sdl = renderComponentToSDL(tester.program, <InterfaceType type={Timestamped} />);
56+
57+
expect(sdl).toContain("interface Timestamped {");
58+
expect(sdl).toContain("createdAt: String!");
59+
expect(sdl).toContain("updatedAt: String!");
60+
expect(sdl).toContain("version: Int!");
61+
});
62+
63+
it("renders optional fields as nullable", async () => {
64+
const { Described } = await tester.compile(
65+
t.code`
66+
@Interface
67+
model ${t.model("Described")} { description?: string; }
68+
`,
69+
);
70+
71+
const sdl = renderComponentToSDL(tester.program, <InterfaceType type={Described} />);
72+
73+
expect(sdl).toContain("description: String");
74+
expect(sdl).not.toContain("description: String!");
75+
});
76+
});

0 commit comments

Comments
 (0)