I want to create an asynchronuous OpenAPI interface. Async jobs return a 202 and a location header to query later.
This is my OpenAPI document:
--- components: headers: JobLocation: description: Job status URL schema: type: string JobRetryAfter: description: Query delay in seconds schema: oneOf: - minimum: 1 type: integer - type: string schemas: ErrorMessage: description: An error descriptopm example: customer_error: You did not provide a password message: 'Request error: Password is missing' properties: customer_error: description: Error for consumer type: string message: description: Technical error description type: string required: - message type: object Job: properties: code: type: integer completedAt: format: date-time nullable: true type: string createdAt: format: date-time type: string jobId: type: string result: $ref: '#/components/schemas/Result' status: enum: - initiated - pending - completed - failed type: string required: - jobId - code - status - createdAt type: object JobAccepted: example: jobId: job-12345 statusUrl: /jobs/job-12345 properties: jobId: type: string statusUrl: type: string type: object Notification: properties: message: description: Beschreibung der Benachrichtigung type: string notificationID: description: ein eindeutiger Bezeichner dieser Benachrichtigung type: string path: description: Aktuell unbenutzt type: string severity: description: Einschätzung der Benachrichtigung enum: - CRITICAL - ERROR - WARNING - INFO type: string required: - notificationID - severity - message PayloadBaseObject: properties: verb: type: string required: - verb type: object Result: properties: notifications: items: $ref: '#/components/schemas/Notification' type: array payload: items: discriminator: propertyName: entityType oneOf: - $ref: '#/components/schemas/SyncMock' type: array type: object SyncMock: allOf: - $ref: '#/components/schemas/PayloadBaseObject' - properties: entityType: enum: - mock-details type: string reqkey: type: string result: properties: async: description: demo type: integer type: object required: - entityType - reqkey type: object description: Demo of a synchronuous API call securitySchemes: http: description: Basic Auth credentials scheme: basic type: http info: title: mock service version: 0.0.1 openapi: 3.0.4 paths: /sync: get: description: Makes a synchronuous API call and waits for the result. There is no timeout. operationId: getMockSync responses: 200: content: application/json: schema: $ref: '#/components/schemas/Job' description: Synchronuous job response security: - http: [] summary: Mocks a synchronuous REST call tags: - mock x-mojo-to: Mock#sync /async: get: description: Makes an asynchronuous API call and does NOT wait for the result. operationId: getMockAsync responses: 202: content: application/json: schema: $ref: '#/components/schemas/JobAccepted' description: Asynchronuous job response security: - http: [] summary: Mocks an asynchronuous REST call tags: - mock x-mojo-to: Mock#async '/job/{jobid}': get: summary: Get job state description: Get job state operationId: retrieveJobStatus parameters: - description: A job ID in: path name: jobid required: true schema: type: string responses: 200: content: application/json: schema: $ref: '#/components/schemas/Job' description: 'Job done' 202: content: application/json: schema: $ref: '#/components/schemas/JobAccepted' description: 'Job not done yet' headers: Location: $ref: '#/components/headers/JobLocation' Retry-After: $ref: '#/components/headers/JobRetryAfter' 404: content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' description: 'Job not found (anymore)' x-mojo-to: Job#status security: - http: [] servers: - description: Mock APIv2 service url: /mock tags: - description: Just mocking name: mock Here is a complete test script to reproduce my issue:
use strict; use warnings; use File::Spec; use FindBin qw< $Bin >; use Test::More; use Test::Exception; use Test::Mojo; my $api = File::Spec->catfile($Bin => q(test.yaml)); my $t = Test::Mojo->new(q(Test::APIv2)); my $prefix = q(/mock); $t->get_ok(qq($prefix/sync))->status_is(200); $t->get_ok(qq($prefix/async))->status_is(202)->header_exists(q(Location)) ->header_like(Location => qr/\/job\/[0-9a-f]+$/) ->header_exists(q(Retry-after)); my $job = $t->tx->res->json(q(/jobId)); ##note(qq(Job ID: $job)); $t->get_ok(qq($prefix/job/$job))->status_is(202); $t->get_ok(qq($prefix/job/$job))->status_is(200); done_testing(); package My::App; use 5.020; # -signatures flag for Mojolicious use Mojo::Base 'Mojolicious' => -signatures; use Carp; use HTTP::Status qw<>; use Try::Tiny; sub startup ($self) { ## avoid (currently) useless warning $self->secrets([qw< abc cde >]); $self->plugin(q(Config)); ## initialize UI $self->helper(ui => sub { state $ui = $self->init_ui() }); ## initialize OpenAPI plugin $self->plugin( OpenAPI => { ## TODO make this a factory call to be better testable url => $self->config->{api_description}, ## make sure the response obeys the scheme validate_response => 1, log_level => q(trace), } ); $self->helper( myrender => sub ($c, $data, %opts) { my $status = $data->{code} or croak(q("code" is missing in response)); if (grep({ $status == $_ } (HTTP::Status::HTTP_ACCEPTED)) and my $jobid = $data->{jobId}) { my $r = $c->url_for(q(retrieveJobStatus) => { jobid => $jobid }) ->to_abs; $c->res->headers->header(Location => $r); my $ra = 3; ## TODO static here? $c->res->headers->header(q(Retry-after) => $ra); } ## end if (grep({ $status == ...})) $c->render(openapi => $data, status => $status); } ); } ## end sub startup sub init_ui ($self) { croak(q(Not interesting here)) } package My::App::Controller::Job; use 5.020; # -signatures flag for Mojolicious use Mojo::Base "Mojolicious::Controller" => -signatures; { my $c; BEGIN { $c = 0 } sub get_c { $c++ } } sub status ($self) { $self = $self->openapi->valid_input or return; my $jobid = $self->param(q(jobid)); my $status = get_c() ? 200 : 202; my $r = $status == 200 ? { 'jobId' => $jobid, 'code' => $status, 'createdAt' => '2025-11-14T16:24:44Z', 'completedAt' => '2025-11-14T16:24:46Z', 'status' => 'completed', 'result' => { 'payload' => [ { 'reqkey' => 'ent1', 'result' => { 'async' => 0 }, 'entityType' => 'mock-details', 'verb' => 'sync' } ] }, } : { 'jobId' => $jobid, 'code' => $status, 'createdAt' => '2025-11-14T16:24:44Z', 'status' => 'initiated', }; $self->myrender($r, status => $status,); } ## end sub status package Test::APIv2; use 5.020; # -signatures flag for Mojolicious use Mojo::Base 'My::App' => -signatures; sub startup ($self) { $self->SUPER::startup; $self->routes->namespaces( [ qw< My::App::Controller Test::APIv2::Controller > ] ); } ## end sub startup sub init_ui ($self) {return} package Test::APIv2::Controller::Mock; use 5.020; # -signatures flag for Mojolicious use Mojo::Base "Mojolicious::Controller" => -signatures; use Carp; use Try::Tiny; sub sync ($self) { $self = $self->openapi->valid_input or return; $self->myrender( { 'createdAt' => '2025-11-14T16:24:44Z', 'code' => 200, 'completedAt' => '2025-11-14T16:24:46Z', 'jobId' => '7092005578957c4aa8695cf304a8f15eea34c92bd22ec62cc6b5721efaa74676', 'result' => { 'payload' => [ { 'reqkey' => 'ent1', 'result' => { 'async' => 0 }, 'entityType' => 'mock-details', 'verb' => 'sync' } ] }, 'status' => 'completed' } ); } ## end sub sync sub async ($self) { $self = $self->openapi->valid_input or return; $self->myrender( { "code" => 202, "createdAt" => "2025-11-14T16:24:46Z", "jobId" => "58b074fbf5e421e0d79df4a5d38ef2e970a49ff51d06bd52718bacec483007f2", "status" => "initiated" } ); } ## end sub async Put this into a directory (e.g. /tmp/mojo) and add the YAML (test.yaml) from above and a configfile test-a_p_iv2.conf to this directory:
{ api_description => q(test.yaml), } ## vim:set filetype=perl: Then execute
prove -v mock.t The first tests (sync and async) work as expected, but when I call the job endpoint, there is a stacktrace:
[2025-11-14 18:01:49.92515] [1276658] [error] [DyHG8MMADFrN] You have to call resolve() before validate() to lookup "#/components/headers/JobLocation". at /usr/share/perl5/JSON/Validator/Schema/Draft201909.pm line 61. JSON::Validator::Schema::Draft201909::_state(JSON::Validator::Schema::OpenAPIv3=HASH(0x5ffda54c88a8), HASH(0x5ffda58eb580), "schema", HASH(0x5ffda5fb4b28)) called at /usr/share/perl5/JSON/Validator/Schema.pm line 155 JSON::Validator::Schema::validate(JSON::Validator::Schema::OpenAPIv3=HASH(0x5ffda54c88a8), Mojo::URL=HASH(0x5ffda5fb2d98), HASH(0x5ffda5fb4b28)) called at /usr/share/perl5/JSON/Validator/Schema/OpenAPIv2.pm line 374 JSON::Validator::Schema::OpenAPIv2::_validate_request_or_response(JSON::Validator::Schema::OpenAPIv3=HASH(0x5ffda54c88a8), "response", ARRAY(0x5ffda5fb24f8), HASH(0x5ffda584c138)) called at /usr/share/perl5/JSON/Validator/Schema/OpenAPIv2.pm line 174 JSON::Validator::Schema::OpenAPIv2::validate_response(JSON::Validator::Schema::OpenAPIv3=HASH(0x5ffda54c88a8), ARRAY(0x5ffda5fb2f60), HASH(0x5ffda5fb51e8)) called at /usr/share/perl5/Mojolicious/Plugin/OpenAPI.pm line 256 Mojolicious::Plugin::OpenAPI::_render(Mojolicious::Renderer=HASH(0x5ffda50396e8), My::App::Controller::Job=HASH(0x5ffda5fb2318), SCALAR(0x5ffda521f090), HASH(0x5ffda5fc4c40)) called at /usr/share/perl5/Mojolicious/Renderer.pm line 229 Mojolicious::Renderer::_render_template(Mojolicious::Renderer=HASH(0x5ffda50396e8), My::App::Controller::Job=HASH(0x5ffda5fb2318), SCALAR(0x5ffda521f090), HASH(0x5ffda5fc4c40)) called at /usr/share/perl5/Mojolicious/Renderer.pm line 108 Mojolicious::Renderer::render(Mojolicious::Renderer=HASH(0x5ffda50396e8), My::App::Controller::Job=HASH(0x5ffda5fb2318)) called at /usr/share/perl5/Mojolicious/Controller.pm line 149 Mojolicious::Controller::render(My::App::Controller::Job=HASH(0x5ffda5fb2318), "openapi", HASH(0x5ffda5fb2f30), "status", 202) called at mock.t line 73 My::App::__ANON__(My::App::Controller::Job=HASH(0x5ffda5fb2318), HASH(0x5ffda5fb2f30), "status", 202) called at /usr/share/perl5/Mojolicious/Controller.pm line 25 Mojolicious::Controller::_Dynamic::myrender(My::App::Controller::Job=HASH(0x5ffda5fb2318), HASH(0x5ffda5fb2f30), "status", 202) called at mock.t line 123 My::App::Controller::Job::status(My::App::Controller::Job=HASH(0x5ffda5fb2318)) called at /usr/share/perl5/Mojolicious.pm line 193 Mojolicious::_action(undef, My::App::Controller::Job=HASH(0x5ffda5fb2318), CODE(0x5ffda53ca6f0), 1) called at /usr/share/perl5/Mojolicious/Plugins.pm line 15 Mojolicious::Plugins::__ANON__() called at /usr/share/perl5/Mojolicious/Plugins.pm line 18 Mojolicious::Plugins::emit_chain(Mojolicious::Plugins=HASH(0x5ffda537a810), "around_action", My::App::Controller::Job=HASH(0x5ffda5fb2318), CODE(0x5ffda53ca6f0), 1) called at /usr/share/perl5/Mojolicious/Routes.pm line 88 Mojolicious::Routes::_action(Test::APIv2=HASH(0x5ffda5039430), My::App::Controller::Job=HASH(0x5ffda5fb2318), CODE(0x5ffda53ca6f0), 1) called at /usr/share/perl5/Mojolicious/Routes.pm line 161 Mojolicious::Routes::_controller(Mojolicious::Routes=HASH(0x5ffda3666430), Mojolicious::Controller=HASH(0x5ffda5fb1750), HASH(0x5ffda5fbb368), 1) called at /usr/share/perl5/Mojolicious/Routes.pm line 44 Mojolicious::Routes::continue(Mojolicious::Routes=HASH(0x5ffda3666430), Mojolicious::Controller=HASH(0x5ffda5fb1750)) called at /usr/share/perl5/Mojolicious/Routes.pm line 52 Mojolicious::Routes::dispatch(Mojolicious::Routes=HASH(0x5ffda3666430), Mojolicious::Controller=HASH(0x5ffda5fb1750)) called at /usr/share/perl5/Mojolicious.pm line 127 Mojolicious::dispatch(Test::APIv2=HASH(0x5ffda5039430), Mojolicious::Controller=HASH(0x5ffda5fb1750)) called at /usr/share/perl5/Mojolicious.pm line 136 Mojolicious::__ANON__(undef, Mojolicious::Controller=HASH(0x5ffda5fb1750)) called at /usr/share/perl5/Mojolicious/Plugins.pm line 15 Mojolicious::Plugins::__ANON__() called at /usr/share/perl5/Mojolicious.pm line 203 eval {...} called at /usr/share/perl5/Mojolicious.pm line 203 Mojolicious::_exception(CODE(0x5ffda5fb2b58), Mojolicious::Controller=HASH(0x5ffda5fb1750)) called at /usr/share/perl5/Mojolicious/Plugins.pm line 15 Mojolicious::Plugins::__ANON__() called at /usr/share/perl5/Mojolicious/Plugins.pm line 18 Mojolicious::Plugins::emit_chain(Mojolicious::Plugins=HASH(0x5ffda537a810), "around_dispatch", Mojolicious::Controller=HASH(0x5ffda5fb1750)) called at /usr/share/perl5/Mojolicious.pm line 141 Mojolicious::handler(Test::APIv2=HASH(0x5ffda5039430), Mojo::Transaction::HTTP=HASH(0x5ffda5f3a4c8)) called at /usr/share/perl5/Mojo/Server.pm line 72 Mojo::Server::__ANON__(Mojo::Server::Daemon=HASH(0x5ffda5f24a00), Mojo::Transaction::HTTP=HASH(0x5ffda5f3a4c8)) called at /usr/share/perl5/Mojo/EventEmitter.pm line 15 Mojo::EventEmitter::emit(Mojo::Server::Daemon=HASH(0x5ffda5f24a00), "request", Mojo::Transaction::HTTP=HASH(0x5ffda5f3a4c8)) called at /usr/share/perl5/Mojo/Server/Daemon.pm line 103 Mojo::Server::Daemon::__ANON__(Mojo::Transaction::HTTP=HASH(0x5ffda5f3a4c8)) called at /usr/share/perl5/Mojo/EventEmitter.pm line 15 Mojo::EventEmitter::emit(Mojo::Transaction::HTTP=HASH(0x5ffda5f3a4c8), "request") called at /usr/share/perl5/Mojo/Transaction/HTTP.pm line 60 Mojo::Transaction::HTTP::server_read(Mojo::Transaction::HTTP=HASH(0x5ffda5f3a4c8), "GET /mock/job/58b074fbf5e421e0d79df4a5d38ef2e970a49ff51d06bd5"...) called at /usr/share/perl5/Mojo/Server/Daemon.pm line 224 Mojo::Server::Daemon::_read(Mojo::Server::Daemon=HASH(0x5ffda5f24a00), "508eabecdacc338f5072fe4f078ab562", "GET /mock/job/58b074fbf5e421e0d79df4a5d38ef2e970a49ff51d06bd5"...) called at /usr/share/perl5/Mojo/Server/Daemon.pm line 202 Mojo::Server::Daemon::__ANON__(Mojo::IOLoop::Stream=HASH(0x5ffda5f41d70)) called at /usr/share/perl5/Mojo/EventEmitter.pm line 15 Mojo::EventEmitter::emit(Mojo::IOLoop::Stream=HASH(0x5ffda5f41d70), "read", "GET /mock/job/58b074fbf5e421e0d79df4a5d38ef2e970a49ff51d06bd5"...) called at /usr/share/perl5/Mojo/IOLoop/Stream.pm line 109 Mojo::IOLoop::Stream::_read(Mojo::IOLoop::Stream=HASH(0x5ffda5f41d70)) called at /usr/share/perl5/Mojo/IOLoop/Stream.pm line 57 Mojo::IOLoop::Stream::__ANON__(Mojo::Reactor::EV=HASH(0x5ffda4b14e50)) called at /usr/share/perl5/Mojo/Reactor/Poll.pm line 141 eval {...} called at /usr/share/perl5/Mojo/Reactor/Poll.pm line 141 Mojo::Reactor::Poll::_try(Mojo::Reactor::EV=HASH(0x5ffda4b14e50), "I/O watcher", CODE(0x5ffda5f422c8), 0) called at /usr/share/perl5/Mojo/Reactor/EV.pm line 54 Mojo::Reactor::EV::__ANON__(EV::IO=SCALAR(0x5ffda5f42118), 1) called at /usr/share/perl5/Mojo/Reactor/EV.pm line 32 eval {...} called at /usr/share/perl5/Mojo/Reactor/EV.pm line 32 Mojo::Reactor::EV::start(Mojo::Reactor::EV=HASH(0x5ffda4b14e50)) called at /usr/share/perl5/Mojo/IOLoop.pm line 134 Mojo::IOLoop::start(Mojo::IOLoop=HASH(0x5ffda4ae5fd0)) called at /usr/share/perl5/Mojo/UserAgent.pm line 67 Mojo::UserAgent::start(Mojo::UserAgent=HASH(0x5ffda5031418), Mojo::Transaction::HTTP=HASH(0x5ffda5f6fba8)) called at /usr/share/perl5/Test/Mojo.pm line 400 Test::Mojo::_request_ok(Test::Mojo=HASH(0x5ffda2da6970), Mojo::Transaction::HTTP=HASH(0x5ffda5f6fba8), "/mock/job/58b074fbf5e421e0d79df4a5d38ef2e970a49ff51d06bd52718"...) called at /usr/share/perl5/Test/Mojo.pm line 343 Test::Mojo::_build_ok(Test::Mojo=HASH(0x5ffda2da6970), "GET", "/mock/job/58b074fbf5e421e0d79df4a5d38ef2e970a49ff51d06bd52718"...) called at /usr/share/perl5/Test/Mojo.pm line 131 Test::Mojo::get_ok(Test::Mojo=HASH(0x5ffda2da6970), "/mock/job/58b074fbf5e421e0d79df4a5d38ef2e970a49ff51d06bd52718"...) called at mock.t line 24 I understand it comes from JSON::Validator, but why does it bite me only on the job endpoint?