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?