@campoint/graphql-contracts
v0.3.0
Published
Canonical shared GraphQL SDL contracts (scalars, etc.) to be consistent across services.
Keywords
Readme
@campoint/graphql-contracts
Native npm distribution of the canonical GraphQL SDL contracts from
contracts/ in the
con-graphql repo.
Usage
const { scalars, objects, interfaces } = require("@campoint/graphql-contracts");
const fs = require("fs");
const dateTimeSdl = fs.readFileSync(scalars.DateTime, "utf8");
const pageInfoSdl = fs.readFileSync(objects.PageInfo, "utf8");
const nodeSdl = fs.readFileSync(interfaces.Node, "utf8");Source of truth
This package is a thin wrapper — it does not maintain its own copy of the
SDL. scripts/copy-contracts.js pulls ../../contracts into this package's
contracts/ directory on every npm pack/npm publish (via the prepack
script), so this package is always in lockstep with the canonical contract
files at the tagged commit.
Versioning
Published from CI whenever a vX.Y.Z git tag is pushed to the repo; the
package version always matches the tag (minus the v prefix).
Verify your integration
This package only ships the contract — it does not check that your
service's scalar matches it. That check belongs in your own service's CI,
using your own test framework. Here's a Jest example for a service using
graphql-scalars:
// datetime.contract.test.ts
import { readFileSync } from "fs";
import { scalars } from "@campoint/graphql-contracts";
import { DateTimeResolver } from "graphql-scalars";
function readContract() {
const sdl = readFileSync(scalars.DateTime, "utf8");
const nameMatch = sdl.match(/scalar\s+(\w+)/);
const specifiedByMatch = sdl.match(/@specifiedBy\(url:\s*"([^"]+)"\)/);
if (!nameMatch || !specifiedByMatch) {
throw new Error("Could not parse the shared DateTime contract");
}
return { name: nameMatch[1], specifiedByUrl: specifiedByMatch[1] };
}
describe("DateTime scalar contract", () => {
const contract = readContract();
it("this service's scalar name matches the shared contract", () => {
expect(contract.name).toBe("DateTime");
});
it("this service's specifiedBy URL matches the shared contract", () => {
expect(DateTimeResolver.specifiedByURL).toBe(contract.specifiedByUrl);
});
});Steps to adopt this in your service:
npm install --save-dev @campoint/graphql-contracts- Add a test like the one above (adjust the assertions to match whichever scalar library your service actually uses).
- Run it as part of your own service's CI pipeline — not this repo's.
- Re-run it whenever you bump
@campoint/graphql-contractsto catch drift early.
Object types (PageInfo / OffsetPageInfo / SignedUrl / SignedUrlVariant)
Object contracts are exposed under objects and checked against the field
set of your schema rather than a scalar's metadata. graphql's own parser
compares names, types and nullability, order-insensitively:
// pageinfo.contract.test.ts
import { readFileSync } from "fs";
import { parse, print, printSchema, Kind } from "graphql";
import { objects } from "@campoint/graphql-contracts";
import { mySchema } from "../src/schema";
function fieldsOf(sdl: string, typeName: string): string[] {
const def = parse(sdl).definitions.find(
(d) => d.kind === Kind.OBJECT_TYPE_DEFINITION && d.name.value === typeName,
);
if (!def || !("fields" in def) || !def.fields) {
throw new Error(`${typeName} not found`);
}
return def.fields.map((f) => `${f.name.value}: ${print(f.type)}`).sort();
}
it("this service's PageInfo matches the shared contract", () => {
expect(fieldsOf(printSchema(mySchema), "PageInfo")).toEqual(
fieldsOf(readFileSync(objects.PageInfo, "utf8"), "PageInfo"),
);
});The same test works for OffsetPageInfo, SignedUrl and SignedUrlVariant —
swap the type name and objects.PageInfo for the matching objects.* entry.
This checks the shape only; for the pagination pair it does not check that
your service adopted the right one of the two. PageInfo and
OffsetPageInfo are alternatives, not to be combined on the same list — see
docs/objects/PageInfo.md and
docs/objects/OffsetPageInfo.md.
Because the comparison prints field types, it also covers the scalars
these types depend on — a service that left startCursor as String
instead of Cursor, totalCount as Int! instead of Long!, or
expiresAt as String! instead of DateTime!, fails here, with no separate
scalar test needed.
SignedUrl carries three further clauses that are behavioural — the
expiry must be the one your CDN actually enforces, no no-expiry link may be
served, and entitlement must be re-checked at each mint — which no SDL
comparison can see. See
docs/objects/SignedUrl.md.
SignedUrlVariant adds two clauses shape cannot reach either: a variant with
no expiry is dropped from the set rather than emitted unexpiring, and the
type is not independently adoptable — a service exposing it must match
SignedUrl too, so assert both or neither. See
docs/objects/SignedUrlVariant.md.
Interfaces (Node)
Node is exposed under interfaces. Because the Global Object Identification
spec says "exactly one field", name plus shape really is identity here — so the
field-set comparison above is the whole structural check, run against
Kind.INTERFACE_TYPE_DEFINITION instead:
// node.contract.test.ts
import { readFileSync } from "fs";
import { parse, print, printSchema, Kind } from "graphql";
import { interfaces } from "@campoint/graphql-contracts";
import { mySchema } from "../src/schema";
function interfaceFieldsOf(sdl: string, typeName: string): string[] {
const def = parse(sdl).definitions.find(
(d) => d.kind === Kind.INTERFACE_TYPE_DEFINITION && d.name.value === typeName,
);
if (!def || !("fields" in def) || !def.fields) {
throw new Error(`${typeName} not found`);
}
return def.fields.map((f) => `${f.name.value}: ${print(f.type)}`).sort();
}
it("this service's Node matches the shared contract", () => {
expect(interfaceFieldsOf(printSchema(mySchema), "Node")).toEqual(
interfaceFieldsOf(readFileSync(interfaces.Node, "utf8"), "Node"),
);
});
it("this service's Node carries the contract marker", () => {
const type = mySchema.getType("Node");
expect(type?.description).toContain(
"https://bitbucket.org/campoint/con-graphql/src/main/docs/interfaces/Node.md",
);
});
it("this service exposes the root node lookup", () => {
const node = mySchema.getQueryType()?.getFields().node;
expect(node && print(node.astNode!.type)).toBe("Node");
expect(node?.args.map((a) => `${a.name}: ${print(a.astNode!.type)}`)).toEqual(["id: ID!"]);
});Three things the structural test cannot see, and one it deliberately checks loosely:
@specifiedByis unusable here — it isSCALAR-only, and applied directives do not survive introspection at all. TheContract:line in the type description is the adoption marker instead, which is why the second test above matches it by substring rather than by equality: the URL moves if the spec-page question indocs/candidates.mdis settled differently.- Adopting
Nodemakesidmean Relay identity across your whole surface, input objects and ID-list filters included. A Relay id arriving where a domain id is expected does not error — it matches nothing. - Global uniqueness, lifetime stability, and authorization parity between
node(id:)and the equivalent domain lookup need a runtime probe, not an SDL comparison.
The full rules, including what a stitching gateway must check before merging
this interface across services, are in
docs/interfaces/Node.md.
What it does not reach is the Connection and Edge types around
PageInfo, which are per-service and have no shared SDL contract. The
recommended (not yet binding) naming rule for those — every *Connection
has edges/pageInfo, every *Edge has cursor: Cursor!/node — and a
sample assertion for it are in docs/objects/PageInfo.md.
