NAME

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

VERSION

Version 0.01

SYNOPSIS

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

plugin 'GraphQL';

graphql '/graphql' => 'schema/app.graphql', {
	resolvers => {
		Query	=> 'Books',			  # MyApp::Controller::Books
		Mutation => { rename => 'Books#rename' },
	},
	context  => 'Books#context',
	graphiql => 1,
};

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;

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, 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)