-5

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?

1
  • That's a lot to digest. Make the example smaller and more targeted and you might have more luck getting people to look to it. You might even discover the porblem yourself in that process. Commented Nov 14 at 23:26

1 Answer 1

2

Edit: I got one step further. By changing the schema the behaviour changed:

x-header-location: &HEADER_LOCATION description: Job status URL schema: type: string x-header-retry-after: &HEADER_RETRY_AFTER description: Query delay in seconds schema: oneOf: - type: integer minimum: 1 - type: string components: 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: description: Asynchronuous job response content: application/json: schema: $ref: '#/components/schemas/JobAccepted' headers: Location: *HEADER_LOCATION Retry-after: *HEADER_RETRY_AFTER 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: description: 'Job done' content: application/json: schema: $ref: '#/components/schemas/Job' 202: description: 'Job not done yet' content: application/json: schema: $ref: '#/components/schemas/JobAccepted' headers: Location: *HEADER_LOCATION Retry-after: *HEADER_RETRY_AFTER 404: description: 'Job not found (anymore)' content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' x-mojo-to: Job#status security: - http: [] servers: - description: Mock APIv2 service url: /mock tags: - description: Just mocking name: mock 

I resolved the header reference manually (x-header-* anchors) and also added these headers to metho. This is the required action from the error message. Now I see

[trace] OpenAPI >>> GET /mock/async [{"message":"Expected string - got Mojo::URL.","path":"\/Location"},{"message":"All of the oneOf rules match.","path":"\/Retry-after"}] ... [trace] OpenAPI >>> GET /mock/job/58b074fbf5e421e0d79df4a5d38ef2e970a49ff51d06bd52718bacec483007f2 [{"message":"Expected string - got Mojo::URL.","path":"\/Location"},{"message":"All of the oneOf rules match.","path":"\/Retry-after"}] 

This is also consistent with the formerly passing GET /mock/async. So maybe only my description is wrong.

Edit #2: Reading {"message":"Expected string - got Mojo::URL.","path":"\/Location"} I changed

 my $r = $c->url_for(q(retrieveJobStatus) => { jobid => $jobid }) ->to_abs; 

to

 my $r = $c->url_for(q(retrieveJobStatus) => { jobid => $jobid }) ->to_abs->to_string; 

and this error is gone.

Finally: The resolution is to change the oneOf of x-header-retry-after to anyOf and all problems are gone.

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.