WooCommerce Dual API

Creating a dual API in a plugin

A plugin defines its own code API and gets a matching GraphQL endpoint through the WooCommerce Dual API plugin. The plugin writes its own classes under src/Api/, runs the builder against them, commits the generated tree to its own repo, and registers a dedicated endpoint.

The full, runnable reference for everything here is the woocommerce-simple-events plugin. The snippets below are condensed; see that repo for complete files.

Prerequisites

The endpoint is dedicated: each plugin registers its own REST route.

1. Lay out the code API

Use the directory conventions under your plugin’s namespace:

my-plugin/
├── bin/build-api.php
├── src/Api/
│   ├── Queries/        Mutations/   Types/   InputTypes/
│   ├── Enums/          Interfaces/  Scalars/
│   ├── Attributes/         ← custom attributes (optional)
│   └── Infrastructure/     ← custom convention classes (optional)
└── src/Internal/Api/Autogenerated/   ← generated; committed

Map both trees in your composer.json:

{
    "autoload": {
        "psr-4": {
            "MyPlugin\\Api\\": "src/Api/",
            "MyPlugin\\Internal\\Api\\Autogenerated\\": "src/Internal/Api/Autogenerated/"
        }
    }
}

Writing the code-API classes is covered in Writing the code API.

2. Add the build script

A plugin’s bin/build-api.php is a thin wrapper around ApiBuilder::run_for_plugin(). It locates the WooCommerce Dual API plugin, requires its autoloader (which is what makes ApiBuilder available), and hands over the plugin root and namespace prefix:

<?php

declare(strict_types=1);

if ( PHP_SAPI !== 'cli' ) {
    http_response_code( 403 );
    exit;
}

$plugin_root   = dirname( __DIR__ );
$dual_api_path = getenv( 'WC_DUAL_API_PATH' ) ?: dirname( $plugin_root ) . '/woocommerce-dual-api';

if ( ! is_file( $dual_api_path . '/vendor/autoload.php' ) ) {
    fwrite( STDERR, "WooCommerce Dual API plugin not found at {$dual_api_path}. Set WC_DUAL_API_PATH to its directory.\n" );
    exit( 1 );
}
require_once $dual_api_path . '/vendor/autoload.php';

use Automattic\WooCommerce\Api\Infrastructure\DesignTime\ApiBuilder;

ApiBuilder::run_for_plugin( $plugin_root, 'MyPlugin' );

run_for_plugin( $plugin_root, $namespace_prefix, $text_domain = null ) derives the conventional dirs/namespaces: source at $plugin_root/src/Api (namespace <prefix>\Api), output at $plugin_root/src/Internal/Api/Autogenerated (namespace <prefix>\Internal\Api\Autogenerated). The generated code translates its schema descriptions with your plugin’s text domain, which defaults to the name of the plugin directory; pass it explicitly if yours differs.

Run it with php bin/build-api.php (set WC_DUAL_API_PATH if the dual API plugin isn’t a sibling directory of your plugin), and commit the generated tree. See Building and staleness checks, and add the staleness check to your CI.

3. Register the endpoint

In your plugin bootstrap, register the route through the engine’s Main:

use Automattic\WooCommerce\Api\Infrastructure\Main as DualApiMain;

add_action( 'plugins_loaded', static function () {
    if ( ! method_exists( DualApiMain::class, 'register_graphql_endpoint' ) ) {
        return; // The WooCommerce Dual API plugin is not active.
    }
    DualApiMain::register_graphql_endpoint( __DIR__, 'my-plugin', '/graphql' );
} );

The first argument may be your plugin directory (the controller class is resolved by convention) or the fully-qualified controller class name. The method_exists() guard keeps your plugin loading cleanly when the dual API plugin is missing or inactive (or when WooCommerce is too old): the class simply doesn’t exist. Your endpoint goes through the engine’s request pipeline and inherits the site-wide GraphQL settings.

4. Reuse or replace the convention classes

ApiBuilder detects a small set of convention classes at <your-namespace>\Api\Infrastructure\*. Ship one only when you need to diverge from the default; otherwise the engine’s default applies.

Class Default Ship your own to…
ClassResolver wc_get_container()->get() Instantiate commands through your own DI container.
PrincipalResolver wraps wp_get_current_user() Authenticate against something other than WP users. Its return type declares your principal type.
Principal wraps WP_User Carry your own identity/permission data. Add is_authenticated(), and can_introspect()/can_query_metadata()/can_use_debug_mode() to opt into those surfaces.
HttpStatusResolver none (per-error-code map) Override response HTTP status, e.g. always return 200.

See Infrastructure classes for exact signatures.

Custom authentication example (HTTP basic against a fixed credential, role in a header):

namespace MyPlugin\Api\Infrastructure;

use Automattic\WooCommerce\Api\InvalidTokenException;

final class PrincipalResolver {
    public function resolve_principal( \WP_REST_Request $request ): EventsPrincipal {
        $user = $_SERVER['PHP_AUTH_USER'] ?? null;
        $pass = $_SERVER['PHP_AUTH_PW'] ?? null;
        if ( null === $user || null === $pass ) {
            return EventsPrincipal::anonymous();
        }
        if ( 'password' !== $pass || ! isset( EventsPrincipal::SCOPES_BY_ROLE[ $user ] ) ) {
            throw new InvalidTokenException();
        }
        return new EventsPrincipal( $user, $user, EventsPrincipal::SCOPES_BY_ROLE[ $user ] );
    }
}

5. Define custom attributes and exceptions (optional)

6. Engine-decoupling guarantee

Your committed generated tree references only the public Api\Infrastructure\* surface, never the underlying GraphQL engine (Vendor\GraphQL\*). If the dual API plugin ever swaps engines, that surface absorbs the change and your already-committed generated code keeps working. The flip side: never write code (generated or hand-written) that imports from Vendor\GraphQL\* or from Internal\Api\*. See Architecture and Extending the infrastructure.