NAME

Punk::Plugin::GraphQL - mount GraphQL::Houtou endpoints into a Punk app

VERSION

Version 0.02

SYNOPSIS

package MyApp;
use Punk;
use Punk::Plugin::GraphQL;

plugin 'GraphQL';

# the short form: a path and an SDL file
graphql '/graphql' => 'schema/app.graphql';

# an SDL string instead of a file - anything with a brace or a
# newline is read as SDL, everything else as a filename
graphql '/hello' => 'type Query { hello: String }', {
	resolvers => { Query => { hello => sub { 'hi' } } },
};

# a schema built by hand - resolvers belong to it and cannot be
# passed here
my $schema = GraphQL::Houtou::build_schema($sdl, resolvers => \%r);
graphql '/prebuilt' => $schema;

# the usual production endpoint: controllers for resolvers, a
# per-request context, a guard, the console behind it
graphql '/books' => 'schema/books.graphql', {
	resolvers => {
		Query    => 'Books',   # MyApp::Controller::Books
		Mutation => { rename => 'Books#rename' },
	},
	context  => 'Books#context',
	guard    => 'Auth#require_token',
	graphiql => 1,
};

# one hashref, schema and options together - the same endpoint
# written the other way round
graphql '/books-again' => {
	schema    => 'schema/books.graphql',
	resolvers => { Query => 'Books' },
	context   => 'Books#context',
};

# large or expensive documents: every ceiling raised deliberately
graphql '/reports' => 'schema/reports.graphql', {
	resolvers           => { Query => 'Reports' },
	max_body            => 8 * 1024 * 1024,  # bytes, default 1 MiB
	max_cost            => 250_000,          # weighted, default 10_000
	default_list_size   => 100,              # unsized list, default 10
	max_depth           => 30,               # per execution
	max_nodes           => 20_000,           # per execution
	program_cache_max   => 2_000,            # compiled queries kept
	allow_introspection => 0,                # shut in production
};

package MyApp::Controller::Books;

sub users {
	my ($root, $args, $ctx) = @_;
	return $ctx->{db}->users($args->{first});
}

sub context {
	my ($c) = @_;
	return ({ db => $c->db });
}

1;

Or declare the single endpoint at registration and skip the keyword entirely:

plugin 'GraphQL' => {
	path      => '/graphql',
	schema    => 'schema/app.graphql',
	resolvers => { Query => 'Books' },
};

DESCRIPTION

This plugin serves GraphQL over HTTP from a Punk application, executed by GraphQL::Houtou. Each declared endpoint compiles its schema and native runtime once at register time - in the server parent, so the compiled artifacts are shared copy-on-write across preforked workers - and mounts a POST route through Punk's ordinary routing.

The request path is deliberately short. Punk's C dispatcher matches the route; the request envelope is decoded by $c->req->json through the File::Raw::JSON C ABI; GraphQL::Houtou executes the query on its XS VM and renders the response straight to UTF-8 JSON bytes; and the handler returns those bytes as a PSGI triplet, which Punk's C finish path passes through without re-encoding.

THE graphql KEYWORD

graphql PATH => SCHEMA, \%options;
graphql PATH => { schema => SCHEMA, %options };

use Punk::Plugin::GraphQL installs the keyword into the calling Punk application class. Either ordering works: declared before plugin 'GraphQL', endpoints only record and everything expensive - the SDL read, schema build, runtime compile, mounting - runs at registration, in declaration order; declared after it, an endpoint mounts at the declaration itself. A mismatch croaks at to_app time: endpoints declared but the plugin never registered, or the plugin registered but no endpoint ever declared.

SCHEMA is one of:

  • a filename - read at register time and compiled as SDL

  • an SDL string - anything containing a brace or newline

  • a GraphQL::Houtou::Schema object - used as-is; the resolvers option is rejected because resolvers cannot be attached to an already built schema

Multiple graphql declarations mount independent endpoints, each with its own schema and runtime.

OPTIONS

resolvers

Hashref of resolver maps, keyed by type. Resolvers take the same targets as Punk routes - a coderef, or a 'Controller#method' string resolved against the application's ::Controller:: namespace through the same resolver the router uses, so a typo croaks at boot naming the class and method:

resolvers => {
	Query	=> 'Books',					# a controller class
	Mutation => { rename => 'Books#rename' },
	Pet	  => { resolve_type => 'Pets#kind' },
},

A whole type may name a controller class (here MyApp::Controller::Books): every field the SDL declares on that type which has a same-named method on the class is wired to it, fields without one keep the default resolver, and a class matching no fields at all croaks at boot. On abstract types the class's resolve_type method, if any, is wired as the type discriminator.

Resolver methods are called with GraphQL::Houtou's resolver signature - ($source, $args, $context, $info) - not a route handler's ($c); the request context crosses over through the context builder. The resolved maps are passed to build_schema - see "Building a schema from SDL" in GraphQL::Houtou.

context

Coderef or 'Controller#method' target called once per request with the Punk::Context. Its return is list-assigned as (context, on_stall): the first value becomes the GraphQL execution context, the optional second is the batching hook that flushes GraphQL::Houtou::DataLoader instances. Create loaders inside this builder - one per request - so their caches are request-scoped.

context => sub {
	my ($c) = @_;
	my $users = GraphQL::Houtou::DataLoader->new(
		batch => sub { load_users($c->db, @{ $_[0] }) });
	return ({ db => $c->db, users => $users },
		GraphQL::Houtou::DataLoader->on_stall_for($users));
},
guard

Coderef or 'Controller#method' target run before the handler, exactly as Punk's under guards work: a reference return short-circuits the request with that response. The GraphiQL page shares the guard.

graphiql

Boolean. When true, GET on the endpoint serves a GraphQL console - a query editor with variables, a headers pane (a JSON object merged into every request, so a guard's Authorization token or any custom header reaches the server; the schema-docs introspection sends them too, with a refresh control for after the token is pasted in), response pane, and introspection-driven schema docs. The page is entirely self-contained: its template, stylesheet, and script ship in this distribution's assets directory, are rendered once at boot through Template::Stencil, and are served from memory as one HTML page that loads nothing from anywhere else - no CDN, no external requests. Leave it off in production, or put it behind a guard.

max_body

Request body size limit in bytes; oversize requests are refused with a 413 before the body is read. Default 1 MiB. 0 disables the check.

max_cost, default_list_size, program_cache_max, async, allow_introspection

Passed through to build_native_runtime - see "Production query cost limits" in GraphQL::Houtou and "Declaring an async schema (async => 1)" in GraphQL::Houtou. Resolvers that return Promise::XS promises need async => 1.

max_depth, max_nodes

Passed through per execution as additional validation limits.

root_value

Passed through per execution as the root source value.

THE WIRE CONTRACT

POST with a Content-Type of application/json (or application/graphql-response+json) and the standard envelope:

{ "query": "...", "variables": {...}, "operationName": "..." }

Responses carry application/graphql-response+json when the request's Accept asks for it, plain application/json otherwise.

Status mapping, following the GraphQL over HTTP specification and matching GraphQL::Houtou::PSGI:

  • 200 - execution reached the schema; field errors, if any, ride inside the response envelope

  • 400 - request errors: malformed JSON, missing query, non-object variables, and any errors-only GraphQL envelope (syntax, validation, cost rejection, unknown operation)

  • 413 - body larger than max_body, refused before reading

  • 415 - wrong content type

  • 405 - wrong method, with an Allow header, from Punk's router

  • 500 - the runtime died; details go to the log, not the client

Note one engine contract this plugin papers over: GraphQL::Houtou's runtime takes the operation name as operation_name and silently ignores the camelCase spelling, so the envelope's operationName is mapped explicitly.

METHODS

new

Plugin constructor, called by Punk's plugin loader.

register ($app, \%opts)

Builds and mounts every recorded endpoint. Options may also declare one endpoint inline: plugin 'GraphQL' => { path => '/graphql', schema => ..., %options } is equivalent to a single graphql keyword.

state_for ($class)

Introspection seam returning the internal per-application state, for tests.

AUTHOR

LNATION, <email at lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)