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
resolversoption 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'sresolve_typemethod, 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 thecontextbuilder. The resolved maps are passed tobuild_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'sunderguards 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.
0disables 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 needasync => 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 reading415 - 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)