openapi-tools/generator-psr-15-webhook-middleware

PSR-15 WebHook Middleware generator

Maintainers

Package info

github.com/php-openapi-tools/generator-psr-15-webhook-middleware

Language:Makefile

pkg:composer/openapi-tools/generator-psr-15-webhook-middleware

Transparency log

Fund package maintenance!

WyriHaximus

Statistics

Installs: 18

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 4

0.1.0 2026-08-26 12:59 UTC

This package is auto-updated.

Last update: 2026-08-27 19:01:55 UTC


README

FileGenerator for OpenAPI Tools that emits a PSR-15 webhook middleware stack: an enum-backed resolver, custom hydrator, and public middleware. When entryPoints.webHookMiddleware is enabled, it replaces the public WebHooks entry point and EventSauce webhook hydrators for that mode.

Continuous Integration Latest Stable Version Total Downloads License

Installation

composer require openapi-tools/generator-psr-15-webhook-middleware

Where it fits

This package runs after gatherer has built a representation and generator-schema has emitted payload schema classes. Register WebHookMiddleware when middleware mode is enabled; set includeWebHookHydrators: false on generator-hydrator so webhook hydration is owned here instead.

flowchart LR
  spec[OpenAPI spec] --> gatherer[Gatherer]
  gatherer --> rep[Representation]
  rep --> schema[Schema generator]
  schema --> wh[WebHook middleware generator]
  wh --> middleware[WebHookMiddleware]
  wh --> resolver[Internal WebHooks + Event enum]
Loading

Example

The snippet below is generated by running the webhook middleware generator against a real OpenAPI input when you run make generate-readme. The example driver lives in etc/docs/README.php.

Input — OpenAPI webhooks (example-input.yaml):

openapi: "3.1.0"
info:
  version: 1.0.0
  title: Swagger Petstore
  license:
    name: MIT
servers:
  - url: http://api.example.com/v1/
webhooks:
  healthCheck:
    post:
      summary: Health check webhook
      description: A health check event sent to verify a webhook configuration.
      operationId: health-check/received
      parameters:
        - name: X-Petstore-Event
          in: header
          example: healthCheck
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/healthCheck"
      responses:
        "200":
          description: Return a 200 status to indicate that the data was received successfully
  inventoryUpdate:
    post:
      summary: Inventory update webhook
      description: An inventory update event sent when stock levels change.
      operationId: inventory/update
      parameters:
        - name: X-Petstore-Event
          in: header
          example: inventoryUpdate
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/inventoryUpdate"
      responses:
        "200":
          description: Return a 200 status to indicate that the data was received successfully
  petLifecycle:
    post:
      summary: Pet lifecycle webhook
      description: A pet lifecycle event resolved by an `eventType` discriminator in the payload.
      operationId: pet-lifecycle/received
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/adoptedPet"
                - $ref: "#/components/schemas/surrenderedPet"
              discriminator:
                propertyName: eventType
                mapping:
                  adopted: "#/components/schemas/adoptedPet"
                  surrendered: "#/components/schemas/surrenderedPet"
      responses:
        "200":
          description: Return a 200 status to indicate that the data was received successfully
  storePolicy:
    post:
      summary: Store policy webhook
      description: A store policy event resolved by an `action` discriminator in the payload.
      operationId: store-policy/received
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/webhook-store-policy-created"
                - $ref: "#/components/schemas/webhook-store-policy-deleted"
              discriminator:
                propertyName: action
                mapping:
                  created: "#/components/schemas/webhook-store-policy-created"
                  deleted: "#/components/schemas/webhook-store-policy-deleted"
      responses:
        "200":
          description: Return a 200 status to indicate that the data was received successfully
components:
  schemas:
    healthCheck:
      type: object
      required:
        - message
      properties:
        message:
          type: string
    inventoryUpdate:
      type: object
      required:
        - sku
        - quantity
      properties:
        sku:
          type: string
        quantity:
          type: integer
    adoptedPet:
      type: object
      required:
        - eventType
        - name
      properties:
        eventType:
          type: string
          enum: [adopted]
        name:
          type: string
    surrenderedPet:
      type: object
      required:
        - eventType
        - name
      properties:
        eventType:
          type: string
          enum: [surrendered]
        name:
          type: string
    webhook-store-policy-created:
      title: store policy created event
      type: object
      required:
        - action
        - policy
        - store
      properties:
        action:
          type: string
          enum: [created]
        franchise:
          $ref: "#/components/schemas/franchise-webhooks"
        registration:
          $ref: "#/components/schemas/store-registration"
        chain:
          $ref: "#/components/schemas/chain-webhooks"
        store:
          $ref: "#/components/schemas/store-webhooks"
        policy:
          $ref: "#/components/schemas/policy_rule"
        customer:
          $ref: "#/components/schemas/customer"
    webhook-store-policy-deleted:
      title: store policy deleted event
      type: object
      required:
        - action
        - policy
        - store
      properties:
        action:
          type: string
          enum: [deleted]
        franchise:
          $ref: "#/components/schemas/franchise-webhooks"
        registration:
          $ref: "#/components/schemas/store-registration"
        chain:
          $ref: "#/components/schemas/chain-webhooks"
        store:
          $ref: "#/components/schemas/store-webhooks"
        policy:
          $ref: "#/components/schemas/policy_rule"
        customer:
          $ref: "#/components/schemas/customer"
    franchise-webhooks:
      type: object
      properties:
        slug:
          type: string
    store-registration:
      type: object
      properties:
        id:
          type: integer
    chain-webhooks:
      type: object
      properties:
        name:
          type: string
    store-webhooks:
      type: object
      required:
        - name
      properties:
        name:
          type: string
    policy_rule:
      type: object
      required:
        - id
      properties:
        id:
          type: integer
    customer:
      type: object
      required:
        - name
      properties:
        name:
          type: string

Output

Running gatherer + WebHookMiddleware::generate() against the input above emits these files. The spec defines four webhooks: healthCheck and inventoryUpdate are resolved by matching headers (and field fingerprints); petLifecycle and storePolicy use an Event enum with discriminator match on eventType and action. All resolution lives in Internal\WebHook\WebHooks: src/Internal/WebHook/Hydrator.php

<?php declare (strict_types=1);
namespace ApiClients\Client\Example\Internal\WebHook;

final class Hydrator
{
    public function hydrate(string $className, array $data): object
    {
        return match ($className) {
            \ApiClients\Client\Example\Schema\HealthCheck::class => $this->hydrateSchemaHealthCheck($data),
            \ApiClients\Client\Example\Schema\InventoryUpdate::class => $this->hydrateSchemaInventoryUpdate($data),
            \ApiClients\Client\Example\Schema\AdoptedPet::class => $this->hydrateSchemaAdoptedPet($data),
            \ApiClients\Client\Example\Schema\SurrenderedPet::class => $this->hydrateSchemaSurrenderedPet($data),
            \ApiClients\Client\Example\Schema\WebhookStorePolicyCreated::class => $this->hydrateSchemaWebhookStorePolicyCreated($data),
            \ApiClients\Client\Example\Schema\WebhookStorePolicyDeleted::class => $this->hydrateSchemaWebhookStorePolicyDeleted($data),
            default => throw new \RuntimeException('Unknown webhook payload class'),
        };
    }
    private function hydrateSchemaHealthCheck(array $data): \ApiClients\Client\Example\Schema\HealthCheck
    {
        return new \ApiClients\Client\Example\Schema\HealthCheck(message: $data['message']);
    }
    private function hydrateSchemaInventoryUpdate(array $data): \ApiClients\Client\Example\Schema\InventoryUpdate
    {
        return new \ApiClients\Client\Example\Schema\InventoryUpdate(sku: $data['sku'], quantity: $data['quantity']);
    }
    private function hydrateSchemaAdoptedPet(array $data): \ApiClients\Client\Example\Schema\AdoptedPet
    {
        return new \ApiClients\Client\Example\Schema\AdoptedPet(eventType: $data['eventType'], name: $data['name']);
    }
    private function hydrateSchemaSurrenderedPet(array $data): \ApiClients\Client\Example\Schema\SurrenderedPet
    {
        return new \ApiClients\Client\Example\Schema\SurrenderedPet(eventType: $data['eventType'], name: $data['name']);
    }
    private function hydrateSchemaWebhookStorePolicyCreated(array $data): \ApiClients\Client\Example\Schema\WebhookStorePolicyCreated
    {
        return new \ApiClients\Client\Example\Schema\WebhookStorePolicyCreated(action: $data['action'], franchise: $data['franchise'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\FranchiseWebhooks::class, $data['franchise']), registration: $data['registration'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\StoreRegistration::class, $data['registration']), chain: $data['chain'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\ChainWebhooks::class, $data['chain']), store: $this->hydrate(\ApiClients\Client\Example\Schema\StoreWebhooks::class, $data['store']), policy: $this->hydrate(\ApiClients\Client\Example\Schema\PolicyRule::class, $data['policy']), customer: $data['customer'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\Customer::class, $data['customer']));
    }
    private function hydrateSchemaWebhookStorePolicyDeleted(array $data): \ApiClients\Client\Example\Schema\WebhookStorePolicyDeleted
    {
        return new \ApiClients\Client\Example\Schema\WebhookStorePolicyDeleted(action: $data['action'], franchise: $data['franchise'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\FranchiseWebhooks::class, $data['franchise']), registration: $data['registration'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\StoreRegistration::class, $data['registration']), chain: $data['chain'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\ChainWebhooks::class, $data['chain']), store: $this->hydrate(\ApiClients\Client\Example\Schema\StoreWebhooks::class, $data['store']), policy: $this->hydrate(\ApiClients\Client\Example\Schema\PolicyRule::class, $data['policy']), customer: $data['customer'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\Customer::class, $data['customer']));
    }
}

src/Internal/WebHook/Event.php

<?php declare (strict_types=1);
namespace ApiClients\Client\Example\Internal\WebHook;

enum Event
{
    case HealthCheck;
    case InventoryUpdate;
    case AdoptedPet;
    case SurrenderedPet;
    case WebhookStorePolicyCreated;
    case WebhookStorePolicyDeleted;
}

src/Internal/WebHook/WebHooks.php

<?php declare (strict_types=1);
namespace ApiClients\Client\Example\Internal\WebHook;

final class WebHooks
{
    public function __construct(private readonly \League\OpenAPIValidation\Schema\SchemaValidator $requestSchemaValidator, private readonly \ApiClients\Client\Example\Internal\WebHook\Hydrator $hydrator)
    {
    }
    public function resolve(array $headers, array $data): \ApiClients\Client\Example\Schema\HealthCheck|\ApiClients\Client\Example\Schema\InventoryUpdate|\ApiClients\Client\Example\Schema\AdoptedPet|\ApiClients\Client\Example\Schema\SurrenderedPet|\ApiClients\Client\Example\Schema\WebhookStorePolicyCreated|\ApiClients\Client\Example\Schema\WebhookStorePolicyDeleted
    {
        $headers = (static function (array $headers): array {
            $loweredHeaders = [];
            foreach ($headers as $key => $value) {
                $loweredHeaders[strtolower($key)] = $value;
            }
            return $loweredHeaders;
        })($headers);
        return match ($this->detectDiscriminatedEvent($data)) {
            Event::AdoptedPet => $this->validatedHydrate(\ApiClients\Client\Example\Schema\AdoptedPet::class, $data),
            Event::SurrenderedPet => $this->validatedHydrate(\ApiClients\Client\Example\Schema\SurrenderedPet::class, $data),
            Event::WebhookStorePolicyCreated => $this->validatedHydrate(\ApiClients\Client\Example\Schema\WebhookStorePolicyCreated::class, $data),
            Event::WebhookStorePolicyDeleted => $this->validatedHydrate(\ApiClients\Client\Example\Schema\WebhookStorePolicyDeleted::class, $data),
            default => $this->resolveByHeaders($headers, $data),
        };
    }
    private function detectDiscriminatedEvent(array $data): ?Event
    {
        if (array_key_exists('eventType', $data)) {
            $event = match ($data['eventType']) {
                'adopted' => Event::AdoptedPet,
                'surrendered' => Event::SurrenderedPet,
                default => null,
            };
            if ($event instanceof Event) {
                return $event;
            }
        }
        if (array_key_exists('action', $data)) {
            $event = match ($data['action']) {
                'created' => Event::WebhookStorePolicyCreated,
                'deleted' => Event::WebhookStorePolicyDeleted,
                default => null,
            };
            if ($event instanceof Event) {
                return $event;
            }
        }
        return null;
    }
    private function resolveByHeaders(array $headers, array $data): \ApiClients\Client\Example\Schema\HealthCheck|\ApiClients\Client\Example\Schema\InventoryUpdate
    {
        $error = new \RuntimeException('No webhook matching given headers and data');
        try {
            if ($headers['x-petstore-event'] === 'healthCheck' && array_key_exists('message', $data)) {
                return $this->validatedHydrate(\ApiClients\Client\Example\Schema\HealthCheck::class, $data);
            }
        } catch (\Throwable $throwable) {
            $error = $throwable;
        }
        try {
            if ($headers['x-petstore-event'] === 'inventoryUpdate' && array_key_exists('sku', $data) && array_key_exists('quantity', $data)) {
                return $this->validatedHydrate(\ApiClients\Client\Example\Schema\InventoryUpdate::class, $data);
            }
        } catch (\Throwable $throwable) {
            $error = $throwable;
        }
        throw $error;
    }
    private function validatedHydrate(string $className, array $data): object
    {
        $this->requestSchemaValidator->validate($data, \cebe\openapi\Reader::readFromJson($className::SCHEMA_JSON, \cebe\openapi\spec\Schema::class));
        return $this->hydrator->hydrate(className::class, $data);
    }
}

src/WebHookMiddleware.php

<?php declare (strict_types=1);
namespace ApiClients\Client\Example;

use OpenAPITools\Contract\WebHookHandlerInterface;
use \ApiClients\Client\Example\Internal\WebHook\InvalidWebHookRequestException;
use \ApiClients\Client\Example\Internal\WebHook\WebHooks;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final readonly class WebHookMiddleware implements Psr\Http\Server\MiddlewareInterface
{
    public function __construct(private readonly \ApiClients\Client\Example\Internal\WebHook\WebHooks $webHooks, private readonly \OpenAPITools\Contract\WebHookHandlerInterface $handler, private readonly array $paths = ['/webhook'])
    {
    }
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        if ($this->paths !== [] && !in_array($request->getUri()->getPath(), $this->paths, true)) {
            return $handler->handle($request);
        }
        $body = (string) $request->getBody();
        if ($body === '') {
            throw new InvalidWebHookRequestException('Missing webhook request body');
        }
        $data = json_decode($body, true);
        if (is_array($data) !== true) {
            throw new InvalidWebHookRequestException('Invalid webhook request body');
        }
        $headers = [];
        foreach ($request->getHeaders() as $name => $values) {
            $headers[strtolower($name)] = $values[0];
        }
        try {
            $payload = $this->webHooks->resolve($headers, $data);
        } catch (\Throwable $throwable) {
            throw new InvalidWebHookRequestException('Failed to resolve webhook', 0, $throwable);
        }
        return $this->handler->handle($payload);
    }
}

For each payload variant in the spec, the generator emits:

Class Visibility
WebHookMiddleware Public
Internal\WebHook\WebHooks Internal — enum match for discriminators, header matching fallback otherwise
Internal\WebHook\Event Internal — one enum case per payload variant
Internal\WebHook\Hydrator Internal
Internal\WebHook\InvalidWebHookRequestException Internal

Schema classes (Schema\Ping, Schema\Push, …) are still emitted by generator-schema.

Resolution order

For each webhook delivery, generated code resolves the payload in this order:

  1. Headers — match declared webhook header parameters (e.g. X-Event-Type)
  2. Discriminator — when the spec defines discriminator, match $data[$propertyName]
  3. Field fingerprint — required fields must be present in $data
  4. ValidationSchemaValidator against generated SCHEMA_JSON
  5. HydrationInternal\WebHook\Hydrator maps arrays to readonly schema objects (no EventSauce)

Behaviour

  • Path filter: when $paths is non-empty, only matching request paths are treated as webhooks; all other requests pass through unchanged.
  • Empty paths: when $paths is [], every request is treated as a potential webhook (intended for local development and testing).
  • Strict: invalid JSON, missing body, or failed resolve throws InvalidWebHookRequestException.
  • Happy path: on success, returns $this->handler->handle($payload) directly.

Usage in openapi-client-generator

entryPoints:
  webHookMiddleware: true

Or with explicit paths (overridable at runtime via the middleware constructor):

entryPoints:
  webHookMiddleware:
    paths:
      - /webhook
      - /hooks/github

When middleware mode is enabled, webHooks: true is implied but the public WebHooks / WebHook generators are not registered.

Implement OpenAPITools\Contract\WebHookHandlerInterface to handle resolved payloads:

final readonly class MyWebHookHandler implements WebHookHandlerInterface
{
    public function handle(object $payload): ResponseInterface
    {
        return match ($payload::class) {
            Ping::class => $this->ping($payload),
            default => new EmptyResponse(404),
        };
    }
}

Wire the middleware in your HTTP application:

$middleware = new WebHookMiddleware(
    new Internal\WebHook\WebHooks($requestSchemaValidator, new Internal\WebHook\Hydrator()),
    new MyWebHookHandler(),
    paths: ['/webhook'],
);

In a full client package, WebHookMiddleware is wired into the generator run loop alongside other FileGenerator implementations. A minimal direct invocation looks like this:

use OpenAPITools\Generator\PSR15\WebHook\WebHookMiddleware;
use PhpParser\BuilderFactory;

$generator = new WebHookMiddleware(new BuilderFactory(), defaultPaths: ['/webhook']);

foreach ($generator->generate($package, $representation->namespace($package->namespace)) as $file) {
    // $file->pathPrefix  — e.g. "src"
    // $file->fqcn        — e.g. "WebHookMiddleware"
    // $file->contents    — PhpParser Node
}

See openapi-tools/generator for a complete package configuration.

Related packages

Package Relationship
contract FileGenerator, WebHookHandlerInterface, and Package interfaces
representation Input model consumed by this generator
gatherer Builds the representation from OpenAPI
generator-schema Emits payload schema classes this generator hydrates
generator-hydrator Disable webhook hydrators when this package is active
generator-utils AST builders used by this generator
generator CLI and run loop that orchestrates all generators

Contributing

Please see CONTRIBUTING for details.

License

The MIT License (MIT)

Copyright (c) 2026 Cees-Jan Kiewiet

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.