OpenAPI, Perl & Koha

Perl Koha Con 2023 Helsinki 2023-08-15
Thomas Klausner https://domm.plix.at domm AT plix.at

Permalink for this talk:
https://domm.plix.at/talks/2023_helsinki_openapi

Intro

Thomas Klausner - domm

Doing Perl for 25+ years

My 19th European Perl Conference

But my first Koha Con!

I'm here as the K from HKS3

Mark Hofstetter, David Schmidt & me

https://www.koha-support.eu/

We started working with Koha in 2020:

Migrate the "Steiermärkische Landesbibliothek" to Koha

Since then we've worked with a lot of libraries in Austria, Germany and Hungary

helping with migrations

hosting Koha

and providing custom development

OpenAPI / Swagger

A introduction to OpenAPI Spec

The Spec formerly known as Swagger

There's also going to be a tiny bit of Perl code

and some Koha

https://www.openapis.org/

https://swagger.io/specification/

SOAP

  ( $OpenAPI = $SOAP ) =~ s/ xml / yaml /gx

"... a specification language that provides a standardized means to define your API to others.

You can quickly discover how an API works, configure infrastructure, generate client code, and create test cases for your APIs."

A simple API Spec

 openapi: 3.1.0
 info:
   title: Tic Tac Toe
   description: |
     This API allows writing down marks on a Tic Tac Toe board
     and requesting the state of the board or of individual squares.
   version: 1.0.0
 paths:
   /board:
     get:
       summary: Get the whole board
       description: Retrieves the current state of the board and the winner.
       responses:
         "200":
           description: "OK"
           content:
             ...
 openapi: 3.1.0
 info:
   title: Tic Tac Toe
   description: |
     This API allows writing down marks on a Tic Tac Toe board
     and requesting the state of the board or of individual squares.
   version: 1.0.0
 paths:
   /board:
     get:
       summary: Get the whole board
       description: Retrieves the current state of the board and the winner.
       responses:
         "200":
           description: "OK"
           content:
             ...
 openapi: 3.1.0
 info:
   title: Tic Tac Toe
   description: |
     This API allows writing down marks on a Tic Tac Toe board
     and requesting the state of the board or of individual squares.
   version: 1.0.0
 paths:
   /board:
     get:
       summary: Get the whole board
       description: Retrieves the current state of the board and the winner.
       responses:
         "200":
           description: "OK"
           content:
             ...
 openapi: 3.1.0
 info:
   title: Tic Tac Toe
   description: |
     This API allows writing down marks on a Tic Tac Toe board
     and requesting the state of the board or of individual squares.
   version: 1.0.0
 paths:
   /board:
     get:
       summary: Get the whole board
       description: Retrieves the current state of the board and the winner.
       responses:
         "200":
           description: "OK"
           content:
             ...
 openapi: 3.1.0
 info:
   title: Tic Tac Toe
   description: |
     This API allows writing down marks on a Tic Tac Toe board
     and requesting the state of the board or of individual squares.
   version: 1.0.0
 paths:
   /board:
     get:
       summary: Get the whole board
       description: Retrieves the current state of the board and the winner.
       responses:
         "200":
           description: "OK"
           content:
             ...
 /board:
   get:
     summary: Get the whole board
     description: Retrieves the current state of the board and the winner.
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                   winner:
                     type: string
                     enum: [".", "X", "O"]
                     description: Winner of the game. `.` means nobody has won yet.
                   board:
                     type: array
                     maxItems: 3
                     minItems: 3
                     items:
                         type: array
                         maxItems: 3
                         minItems: 3
                         items:
                           type: string
                           enum: [".", "X", "O"]
 /board:
   get:
     summary: Get the whole board
     description: Retrieves the current state of the board and the winner.
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                   winner:
                     type: string
                     enum: [".", "X", "O"]
                     description: Winner of the game. `.` means nobody has won yet.
                   board:
                     type: array
                     maxItems: 3
                     minItems: 3
                     items:
                         type: array
                         maxItems: 3
                         minItems: 3
                         items:
                           type: string
                           enum: [".", "X", "O"]
 /board:
   get:
     summary: Get the whole board
     description: Retrieves the current state of the board and the winner.
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                   winner:
                     type: string
                     enum: [".", "X", "O"]
                     description: Winner of the game. `.` means nobody has won yet.
                   board:
                     type: array
                     maxItems: 3
                     minItems: 3
                     items:
                         type: array
                         maxItems: 3
                         minItems: 3
                         items:
                           type: string
                           enum: [".", "X", "O"]
 /board:
   get:
     summary: Get the whole board
     description: Retrieves the current state of the board and the winner.
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                   winner:
                     type: string
                     enum: [".", "X", "O"]
                     description: Winner of the game. `.` means nobody has won yet.
                   board:
                     type: array
                     maxItems: 3
                     minItems: 3
                     items:
                         type: array
                         maxItems: 3
                         minItems: 3
                         items:
                           type: string
                           enum: [".", "X", "O"]
 /board:
   get:
     summary: Get the whole board
     description: Retrieves the current state of the board and the winner.
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                   winner:
                     type: string
                     enum: [".", "X", "O"]
                     description: Winner of the game. `.` means nobody has won yet.
                   board:
                     type: array
                     maxItems: 3
                     minItems: 3
                     items:
                         type: array
                         maxItems: 3
                         minItems: 3
                         items:
                           type: string
                           enum: [".", "X", "O"]
 /board:
   get:
     summary: Get the whole board
     description: Retrieves the current state of the board and the winner.
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                   winner:
                     type: string
                     enum: [".", "X", "O"]
                     description: Winner of the game. `.` means nobody has won yet.
                   board:
                     type: array
                     maxItems: 3
                     minItems: 3
                     items:
                         type: array
                         maxItems: 3
                         minItems: 3
                         items:
                           type: string
                           enum: [".", "X", "O"]
 /board:
   get:
     summary: Get the whole board
     description: Retrieves the current state of the board and the winner.
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                   winner:
                     type: string
                     enum: [".", "X", "O"]
                     description: Winner of the game. `.` means nobody has won yet.
                   board:
                     type: array
                     maxItems: 3
                     minItems: 3
                     items:
                         type: array
                         maxItems: 3
                         minItems: 3
                         items:
                           type: string
                           enum: [".", "X", "O"]
 /board:
   get:
     summary: Get the whole board
     description: Retrieves the current state of the board and the winner.
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                   winner:
                     type: string
                     enum: [".", "X", "O"]
                     description: Winner of the game. `.` means nobody has won yet.
                   board:
                     type: array
                     maxItems: 3
                     minItems: 3
                     items:
                         type: array
                         maxItems: 3
                         minItems: 3
                         items:
                           type: string
                           enum: [".", "X", "O"]
 /board:
   get:
     summary: Get the whole board
     description: Retrieves the current state of the board and the winner.
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                   winner:
                     type: string
                     enum: [".", "X", "O"]
                     description: Winner of the game. `.` means nobody has won yet.
                   board:
                     type: array
                     maxItems: 3
                     minItems: 3
                     items:
                         type: array
                         maxItems: 3
                         minItems: 3
                         items:
                           type: string
                           enum: [".", "X", "O"]
 curl -X GET /board
 
 
 
 
 
 curl -X GET /board
 
 200 OK
 Content-Type: application/json

 { "winner": ".", "board" : [ [ ".", ".", "." ], [ ".", "X", "." ],  [ ".", ".", "." ] ] }

Render spec as documentation

A lot of tools available to render the docs into something nice

https://github.com/lyra/openapi-dev-tool

Run locally via docker

Gitlab has a nice OpenAPI renderer

that only works if the filename contains `openapi`:

Live Demo?

Theoretical benefits I haven't used

generate tests

generate client / server code

create TypeScript classes etc

Most common features

(used by me)

Defining Parameters

What arguments does an API endpoint take?

And how shall I pass them?

 /users/{id}:
   get:
     parameters:
     - name: id
       in: path
       required: true
       schema:
         type: integer
         minimum: 1
         maximum: 100
 /users/{id}:
   get:
     parameters:
     - name: id
       in: path
       required: true
       schema:
         type: integer
         minimum: 1
         maximum: 100
 /users/{id}:
   get:
     parameters:
     - name: id
       in: path
       required: true
       schema:
         type: integer
         minimum: 1
         maximum: 100
 /users/{id}:
   get:
     parameters:
     - name: id
       in: path
       required: true
       schema:
         type: integer
         minimum: 1
         maximum: 100
 /users/{id}:
   get:
     parameters:
     - name: id
       in: path
       required: true
       schema:
         type: integer
         minimum: 1
         maximum: 100
 curl -X GET /users/42
 /users/{id}:
   get:
     parameters:
     - name: id
       ....
     - name: detail
       in: query
       schema:
         type: string
         enum: ["overview", "full", "debug"]
 /users/{id}:
   get:
     parameters:
     - name: id
       ....
     - name: detail
       in: query
       schema:
         type: string
         enum: ["overview", "full", "debug"]
 /users/{id}:
   get:
     parameters:
     - name: id
       ....
     - name: detail
       in: query
       schema:
         type: string
         enum: ["overview", "full", "debug"]
 curl -X GET /users/42?detail=full

You can also get params from headers and cookies.

Defining incoming payload

But most APIs take a whole JSON document as the request payload

curl -X POST /users/42 -d '{"email":"foo@example.com","username":"foo"}'

 /users/{id}:
   post:
     parameters:
     - name: id
       in: path
     requestBody:
       required: true
       content:
         application/json:
           schema:
             type: object
             properties:
               email:
                 type: string
                 format: email
               username:
                 type: string
             required:
               - email
 /users/{id}:
   post:
     parameters:
     - name: id
       in: path
     requestBody:
       required: true
       content:
         application/json:
           schema:
             type: object
             properties:
               email:
                 type: string
                 format: email
               username:
                 type: string
             required:
               - email
 /users/{id}:
   post:
     parameters:
     - name: id
       in: path
     requestBody:
       required: true
       content:
         application/json:
           schema:
             type: object
             properties:
               email:
                 type: string
                 format: email
               username:
                 type: string
             required:
               - email
 /users/{id}:
   post:
     parameters:
     - name: id
       in: path
     requestBody:
       required: true
       content:
         application/json:
           schema:
             type: object
             properties:
               email:
                 type: string
                 format: email
               username:
                 type: string
             required:
               - email
 /users/{id}:
   post:
     parameters:
     - name: id
       in: path
     requestBody:
       required: true
       content:
         application/json:
           schema:
             type: object
             properties:
               email:
                 type: string
                 format: email
               username:
                 type: string
             required:
               - email

curl -X POST /users/42 -d '{"email":"foo@example.com","username":"foo"}'

Defining response data

 /users/{id}:
   post:
     ..
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                 last_modified:
                   type: string
                   format: date-time
                   description: "Timestamp of the last modification date"
 /users/{id}:
   post:
     ..
     responses:
       "200":
         description: "OK"
         content:
           application/json:
             schema:
               type: object
               properties:
                 last_modified:
                   type: string
                   format: date-time
                   description: "Timestamp of the last modification date"
 curl -X POST /users/42 -d '{"email":"foo@example.com","username":"foo"}'>

 
 
 
 
 curl -X POST /users/42 -d '{"email":"foo@example.com","username":"foo"}'>

 200 OK
 Content-Type: application/json

 {"last_modified": "2023-08-12T13:13:14Z"}

Or define errors

 /users/{id}:
   post:
     ..
     responses:
       "401"
          ...
       "404"
          ...
       "500"
          ...

Or different content types

 /users/{id}:
   post:
     ..
     responses"
       "200":
         content:
           application/xml
             ...
           text/html
             ...

Just try to not go crazy and stay pragmatic!

Reusing definitions

/users?page=3&items=25

 /users:
   get:
     description: "List of users, paged"
     parameters:
     - name: page
       in: query
       schema:
         type: integer
     - name: items
       in: query
       schema:
         type: integer

/groups?page=3&items=25

/orders?page=3&items=25

DRY applies

Components and $ref to the rescue!

 components:
   parameters:
     page:
       name: page
       in: query
       description: "The page number to list"
       schema:
         type: integer
         minimum: 1
     items:
       name: items
       in: query
       description: "How many items to list per page"
       schema:
         type: integer
         enum: [5, 10, 25, 50, 100]
 components:
   parameters:
     page:
       name: page
       in: query
       description: "The page number to list"
       schema:
         type: integer
         minimum: 1
     items:
       name: items
       in: query
       description: "How many items to list per page"
       schema:
         type: integer
         enum: [5, 10, 25, 50, 100]
 components:
   parameters:
     page:
       name: page
       in: query
       description: "The page number to list"
       schema:
         type: integer
         minimum: 1
     items:
       name: items
       in: query
       description: "How many items to list per page"
       schema:
         type: integer
         enum: [5, 10, 25, 50, 100]
 components:
   parameters:
     page:
       name: page
       in: query
       description: "The page number to list"
       schema:
         type: integer
         minimum: 1
     items:
       name: items
       in: query
       description: "How many items to list per page"
       schema:
         type: integer
         enum: [5, 10, 25, 50, 100]
 components:
   parameters:
     page:
       name: page
       in: query
       description: "The page number to list"
       schema:
         type: integer
         minimum: 1
     items:
       name: items
       in: query
       description: "How many items to list per page"
       schema:
         type: integer
         enum: [5, 10, 25, 50, 100]
 /users:
   get:
     description: "List of users, paged"
     parameters:
     - name: page
       in: query
       schema:
         type: integer
     - name: items
       in: query
       schema:
         type: integer
 /users:
   get:
     description: "List of users, paged"
     parameters:
     - $ref: "#/components/parameters/page"
     - $ref: "#/components/parameters/items"
 
 
 
 
 
 
 /users:
   get:
     description: "List of users, paged"
     parameters:
     - $ref: "#/components/parameters/page"
     - $ref: "#/components/parameters/items"

 
  components:
   parameters:
     page:
 
 /users:
   get:
     description: "List of groups, paged"
     parameters:
     - $ref: "#/components/parameters/page"
     - $ref: "#/components/parameters/items"
 
 
 
 
 
 
 /groups:
   get:
     description: "List of groups, paged"
     parameters:
     - $ref: "#/components/parameters/page"
     - $ref: "#/components/parameters/items"
 
 
 
 
 
 

You can do the same for other things, eg schemas

 /users/{id}:
   get:
     responses:
       "200":
         description: "OK"
         content:
           application/json:
           schema:
             type: object
             properties:
               email:
                 type: string
                 format: email
               username:
                 type: string
               and:
               a_lot:
               more:
               fields:
 /users/{id}:
   post:
     responses:
       "200":
         description: "OK"
         content:
           application/json:
           schema:
             type: object
             properties:
               email:
                 type: string
                 format: email
               username:
                 type: string
               and:
               a_lot:
               more:
               fields:
 components:
   schemas:
     user:
       type: object
       properties:
         email:
           type: string
           format: email
         username:
           type: string
         and:
         a_lot:
         more:
         fields:
 components:
   schemas:
     user:
       type: object
       properties:
         email:
           type: string
           format: email
         username:
           type: string
         and:
         a_lot:
         more:
         fields:
 /users/{id}:
   get:
     responses:
       "200":
         content:
           application/json:
           schema:
             $ref: "#/components/schemas/user"
   post:
     responses:
       "200":
         content:
           application/json:
           schema:
             $ref: "#/components/schemas/user"
 $ref: "#/components/schemas/user"

The value of $ref is actually a URI.

In this case it's using a fragment, so the definition is loaded from the current file.

But you can also use relative and absolute paths or even load schema definitions from external sources.

OpenAPI and JSON Schema

JSON Schema

https://json-schema.org/

Define a schema for any JSON data

(not just for API usage)

eg Kubernetes objects

But you can use a JSON Schema with OpenAPI

and with other tools

A schema

 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$id": "https://example.com/schema/Talk.json",
   "title": "Talk",
   "description": "A 'talk' at a conference",
   "type": "object",
   "properties": {
     "title": {
       "type":"string",
       "description":"The title of the talk."
     },
     "duration": {
       "type":"integer",
       "description":"The duration of the talk in minutes.",
       "minimum":0,
       "maximum":480
     },
     "tags": {
       "type":"array",
       "description":"List of Tags",
       "items": {
         "type": "string"
       },
       "minItems": 1,
       "uniqueItems": true
     }
 }
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$id": "https://example.com/schema/Talk.json",
   "title": "Talk",
   "description": "A 'talk' at a conference",
   "type": "object",
   "properties": {
     "title": {
       "type":"string",
       "description":"The ti``tle of the talk."
     },
     "duration": {
       "type":"integer",
       "description":"The duration of the talk in minutes.",
       "minimum":0,
       "maximum":480
     },
     "tags": {
       "type":"array",
       "description":"List of Tags",
       "items": {
         "type": "string"
       },
       "minItems": 1,
       "uniqueItems": true
     }
 }
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$id": "https://example.com/schema/Talk.json",
   "title": "Talk",
   "description": "A 'talk' at a conference",
   "type": "object",
   "properties": {
     "title": {
       "type":"string",
       "description":"The title of the talk."
     },
     "duration": {
       "type":"integer",
       "description":"The duration of the talk in minutes.",
       "minimum":0,
       "maximum":480
     },
     "tags": {
       "type":"array",
       "description":"List of Tags",
       "items": {
         "type": "string"
       },
       "minItems": 1,
       "uniqueItems": true
     }
 }
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$id": "https://example.com/schema/Talk.json",
   "title": "Talk",
   "description": "A 'talk' at a conference",
   "type": "object",
   "properties": {
     "title": {
       "type":"string",
       "description":"The title of the talk."
     },
     "duration": {
       "type":"integer",
       "description":"The duration of the talk in minutes.",
       "minimum":0,
       "maximum":480
     },
     "tags": {
       "type":"array",
       "description":"List of Tags",
       "items": {
         "type": "string"
       },
       "minItems": 1,
       "uniqueItems": true
     }
 }
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$id": "https://example.com/schema/Talk.json",
   "title": "Talk",
   "description": "A 'talk' at a conference",
   "type": "object",
   "properties": {
     "title": {
       "type":"string",
       "description":"The title of the talk."
     },
     "duration": {
       "type":"integer",
       "description":"The duration of the talk in minutes.",
       "minimum":0,
       "maximum":480
     },
     "tags": {
       "type":"array",
       "description":"List of Tags",
       "items": {
         "type": "string"
       },
       "minItems": 1,
       "uniqueItems": true
     }
 }

Using this schema in OpenAPI

Two steps

First define the schema component

 components:
   schemas:
     talk:
       $ref: https://example.com/schema/Talk.json
 components:
   schemas:
     talk:
       $ref: https://example.com/schema/Talk.json

Use the schema component in your Spec

 /talks/{id}:
   get:
     responses:
       "200":
         content:
           application/json:
           schema:
             $ref: "#/components/schemas/talk"

Schema Validation

While OpenAPI provides tools to validate your payloads etc via an HTTP API

not everything is an HTTP API

cron jobs, fixup scripts, migrations, importers, ...

or even method calls

JSON::Validator

https://metacpan.org/pod/JSON::Validator

 use JSON::Validator;
 my $jv = JSON::Validator->new;

 sub validate( $schema, $data ) {
     $jv->schema( $schema );
     my @errors = $jv->validate( $data );
     if (@errors) {
         MyApp::X::InvalidJSON->throw(
             {   ident   => 'invalid_data',
                 message => 'Cannot validate data using schema %{schema}s',
                 schema  => $schema,
                 errors  => [ map { $_->to_string } @errors ],
             }
         );
     }
     return 1;
 }
 use JSON::Validator;
 my $jv = JSON::Validator->new;

 sub validate( $schema, $data ) {
     $jv->schema( $schema );
     my @errors = $jv->validate( $data );
     if (@errors) {
         MyApp::X::InvalidJSON->throw(
             {   ident   => 'invalid_data',
                 message => 'Cannot validate data using schema %{schema}s',
                 schema  => $schema,
                 errors  => [ map { $_->to_string } @errors ],
             }
         );
     }
     return 1;
 }
 use JSON::Validator;
 my $jv = JSON::Validator->new;

 sub validate( $schema, $data ) {
     $jv->schema( $schema );
     my @errors = $jv->validate( $data );
     if (@errors) {
         MyApp::X::InvalidJSON->throw(
             {   ident   => 'invalid_data',
                 message => 'Cannot validate data using schema %{schema}s',
                 schema  => $schema,
                 errors  => [ map { $_->to_string } @errors ],
             }
         );
     }
     return 1;
 }
 use JSON::Validator;
 my $jv = JSON::Validator->new;

 sub validate( $schema, $data ) {
     $jv->schema( $schema );
     my @errors = $jv->validate( $data );
     if (@errors) {
         MyApp::X::InvalidJSON->throw(
             {   ident   => 'invalid_data',
                 message => 'Cannot validate data using schema %{schema}s',
                 schema  => $schema,
                 errors  => [ map { $_->to_string } @errors ],
             }
         );
     }
     return 1;
 }
 use JSON::Validator;
 my $jv = JSON::Validator->new;

 sub validate( $schema, $data ) {
     $jv->schema( $schema );
     my @errors = $jv->validate( $data );
     if (@errors) {
         MyApp::X::InvalidJSON->throw(
             {   ident   => 'invalid_data',
                 message => 'Cannot validate data using schema %{schema}s',
                 schema  => $schema,
                 errors  => [ map { $_->to_string } @errors ],
             }
         );
     }
     return 1;
 }
 use JSON::Validator;
 my $jv = JSON::Validator->new;

 sub validate( $schema, $data ) {
     $jv->schema( $schema );
     my @errors = $jv->validate( $data );
     if (@errors) {
         MyApp::X::InvalidJSON->throw(
             {   ident   => 'invalid_data',
                 message => 'Cannot validate data using schema %{schema}s',
                 schema  => $schema,
                 errors  => [ map { $_->to_string } @errors ],
             }
         );
     }
     return 1;
 }
 use JSON::Validator;
 my $jv = JSON::Validator->new;

 sub validate( $schema, $data ) {
     $jv->schema( $schema );
     my @errors = $jv->validate( $data );
     if (@errors) {
         MyApp::X::InvalidJSON->throw(
             {   ident   => 'invalid_data',
                 message => 'Cannot validate data using schema %{schema}s',
                 schema  => $schema,
                 errors  => [ map { $_->to_string } @errors ],
             }
         );
     }
     return 1;
 }
 use JSON::Validator;
 my $jv = JSON::Validator->new;

 sub validate( $schema, $data ) {
     $jv->schema( $schema );
     my @errors = $jv->validate( $data );
     if (@errors) {
         MyApp::X::InvalidJSON->throw(
             {   ident   => 'invalid_data',
                 message => 'Cannot validate data using schema %{schema}s',
                 schema  => $schema,
                 errors  => [ map { $_->to_string } @errors ],
             }
         );
     }
     return 1;
 }
 use JSON::Validator;
 my $jv = JSON::Validator->new;

 sub validate( $schema, $data ) {
     $jv->schema( $schema );
     my @errors = $jv->validate( $data );
     if (@errors) {
         MyApp::X::InvalidJSON->throw(
             {   ident   => 'invalid_data',
                 message => 'Cannot validate data using schema %{schema}s',
                 schema  => $schema,
                errors  => [ map { $_->to_string } @errors ],
             }
         );
     }
     return 1;
 }
 use JSON::Validator;
 my $jv = JSON::Validator->new;

 sub validate( $schema, $data ) {
     $jv->schema( $schema );
     my @errors = $jv->validate( $data );
     if (@errors) {
         MyApp::X::InvalidJSON->throw(
             {   ident   => 'invalid_data',
                 message => 'Cannot validate data using schema %{schema}s',
                 schema  => $schema,
                 errors  => [ map { $_->to_string } @errors ],
             }
         );
     }
     return 1;
 }
 /title: Missing property.
 /description: Expected string - got null.

Advanced features

oneOf

 components:
   schemas:
     Dog:
       type: object
       properties:
         bark:
           type: boolean
         breed:
           type: string
           enum: [Dingo, Husky, Retriever, Shepherd]
     Cat:
       type: object
       properties:
         hunts:
           type: boolean
         age:
           type: integer
 components:
   schemas:
     Dog:
       type: object
       properties:
         bark:
           type: boolean
         breed:
           type: string
           enum: [Dingo, Husky, Retriever, Shepherd]
     Cat:
       type: object
       properties:
         hunts:
           type: boolean
         age:
           type: integer
 components:
   schemas:
     Dog:
       type: object
       properties:
         bark:
           type: boolean
         breed:
           type: string
           enum: [Dingo, Husky, Retriever, Shepherd]
     Cat:
       type: object
       properties:
         hunts:
           type: boolean
         age:
           type: integer

Your endpoint can take either a Dog or a Cat.

 schema:
   oneOf:
     - $ref: '#/components/schemas/Cat'
     - $ref: '#/components/schemas/Dog'

Add a hint to make the type clear

 schema:
   oneOf:
     - $ref: '#/components/schemas/Cat'
     - $ref: '#/components/schemas/Dog'
   discriminator:
     propertyName: type
 schema:
   oneOf:
     - $ref: '#/components/schemas/Cat'
     - $ref: '#/components/schemas/Dog'
   discriminator:
     propertyName: type
 components:
   schemas:
     Dog:
       type: object
       properties:
         type:
            type: string
         bark:
           type: boolean
         breed:
           type: string
           enum: [Dingo, Husky, Retriever, Shepherd]
     Cat:
       type: object
       properties:
         type:
            type: string
         hunts:
           type: boolean
         age:
           type: integer

Polymorphism

allOf

 components:
   schemas:
     Error:
       type: object
       properties:
         code:
           type: integer
         message:
           type: text
 components:
   schemas:
     AuthError:
       type: object
       properties:
         code:
           type: integer
         message:
           type: text
         reason:
           type: string
 components:
   schemas:
     NotFoundError:
       type: object
       properties:
         code:
           type: integer
         message:
           type: text
         ressource:
           type: string

Duplicate "code"

 components:
   schemas:
     AuthError:
       allOf:
       - $ref: #/components/schemas/Error
       - type: object
         properties:
           reason:
             type: string
     NotFoundError:
       allOf:
       - $ref: #/components/schemas/Error
       - type: object
         properties:
           ressource:
             type: string

OpenAPI and Koha

Koha 19.11 introduced a new API

specified using OpenAPI

or more exactly Swagger 2.0

https://api.koha-community.org/

http://localhost:8080/api/v1/.html

powered by Mojolicious::Plugin::OpenAPI

https://metacpan.org/pod/Mojolicious::Plugin::OpenAPI

Adding a new endpoint

https://wiki.koha-community.org/wiki/Rest_Api_HowTo

Example: Geosearch

See my talk on Wedndesday about "Koha Geosearch"

Plan

Given a list of biblionumbers (bn), return the latitude and longitude as stored in 035$s and 035$t

Define the object definitions and paths in Swagger

Implement the action in Perl

Define the path in Swagger

 file: api/v1/swagger/paths/biblios_geosearch.yaml
 /biblios/geo:
   get:
     x-mojo-to: Biblios::Geo#biblio_coordinates
     operationId: getBiblioGeoCoordinates
     tags:
       - biblios
     summary: Get biblio Geo (public)
     parameters:
       - name: bn
         in: query
         required: true
         description: Embed list sent in path
         type: array
     produces:
       - application/json
     responses:
       "200":
         description: A biblio
       "401":
         description: Authentication required
         schema:
           $ref: "../swagger.yaml#/definitions/error"
 file: api/v1/swagger/paths/biblios_geosearch.yaml
 /biblios/geo:
   get:
     x-mojo-to: Biblios::Geo#biblio_coordinates
     operationId: getBiblioGeoCoordinates
     tags:
       - biblios
     summary: Get biblio Geo (public)
     parameters:
       - name: bn
         in: query
         required: true
         description: Embed list sent in path
         type: array
     produces:
       - application/json
     responses:
       "200":
         description: A biblio
       "401":
         description: Authentication required
         schema:
           $ref: "../swagger.yaml#/definitions/error"
 file: api/v1/swagger/paths/biblios_geosearch.yaml
 /biblios/geo:
   get:
     x-mojo-to: Biblios::Geo#biblio_coordinates
     operationId: getBiblioGeoCoordinates
     tags:
       - biblios
     summary: Get biblio Geo (public)
     parameters:
       - name: bn
         in: query
         required: true
         description: Embed list sent in path
         type: array
     produces:
       - application/json
     responses:
       "200":
         description: A biblio
       "401":
         description: Authentication required
         schema:
           $ref: "../swagger.yaml#/definitions/error"

Add this path to the global swagger.yaml

 file: api/v1/swagger.yaml
 paths:
  ..
  ...
  /public/biblios/geo:
    $ref: ./paths/biblios_geosearch.yaml#/~1biblios~1geo
 file: api/v1/swagger.yaml
 paths:
  ..
  ...
  /public/biblios/geo:
    $ref: ./paths/biblios_geosearch.yaml#/~1biblios~1geo
 file: api/v1/swagger.yaml
 paths:
  ..
  ...
  /public/biblios/geo:
    $ref: ./paths/biblios_geosearch.yaml#/~1biblios~1geo

I have no idea why the / in the fragment part of $ref is rendered as ~1.

But it seems to be neccessary that way

Implement in Perl

 file: api/v1/swagger/paths/biblios_geosearch.yaml
 /biblios/geo:
   get:
     x-mojo-to: Biblios::Geo#biblio_coordinates

maps to Koha::REST::V1::Biblios::Geo->biblio_coordinates

 file: Koha/REST/V1/Biblios/Geo.pm
 package Koha::REST::V1::Biblios::Geo;
 
 use Mojo::Base 'Mojolicious::Controller';
 use Koha::Biblios;
 
 sub biblio_coordinates {
     my $c = shift->openapi->valid_input or return;
     my $biblionumbers = $c->validation->every_param('bn');
     my @data;
     my $i = 0;
     for my $bn (@$biblionumbers) {
         my $biblio = Koha::Biblios->find( { biblionumber => $bn } );
         my $record = $biblio->metadata->record;
         if ( $record->field('034') ) {
             push @data, {
                 coordinates => [ $record->field('034')->subfield("s"), $record->field('034')->subfield("t") ],
                 title       => sprintf( "<a href='/cgi-bin/koha/opac-detail.pl?biblionumber=%d'>%s</a>",
                                 $bn, $record->field('245')->subfield("a") ),
             };
             $i++;
         }
     }
     return $c->render( status => 200, openapi => {data => \@data, count => $i} );
 }
 1;
 file: Koha/REST/V1/Biblios/Geo.pm
 package Koha::REST::V1::Biblios::Geo;
 
 use Mojo::Base 'Mojolicious::Controller';
 use Koha::Biblios;
 
 sub biblio_coordinates {
     my $c = shift->openapi->valid_input or return;
     my $biblionumbers = $c->validation->every_param('bn');
     my @data;
     my $i = 0;
     for my $bn (@$biblionumbers) {
         my $biblio = Koha::Biblios->find( { biblionumber => $bn } );
         my $record = $biblio->metadata->record;
         if ( $record->field('034') ) {
             push @data, {
                 coordinates => [ $record->field('034')->subfield("s"), $record->field('034')->subfield("t") ],
                 title       => sprintf( "<a href='/cgi-bin/koha/opac-detail.pl?biblionumber=%d'>%s</a>",
                                 $bn, $record->field('245')->subfield("a") ),
             };
             $i++;
         }
     }
     return $c->render( status => 200, openapi => {data => \@data, count => $i} );
 }
 1;
 file: Koha/REST/V1/Biblios/Geo.pm
 package Koha::REST::V1::Biblios::Geo;
 
 use Mojo::Base 'Mojolicious::Controller';
 use Koha::Biblios;
 
 sub biblio_coordinates {
     my $c = shift->openapi->valid_input or return;
     my $biblionumbers = $c->validation->every_param('bn');
     my @data;
     my $i = 0;
     for my $bn (@$biblionumbers) {
         my $biblio = Koha::Biblios->find( { biblionumber => $bn } );
         my $record = $biblio->metadata->record;
         if ( $record->field('034') ) {
             push @data, {
                 coordinates => [ $record->field('034')->subfield("s"), $record->field('034')->subfield("t") ],
                 title       => sprintf( "<a href='/cgi-bin/koha/opac-detail.pl?biblionumber=%d'>%s</a>",
                                 $bn, $record->field('245')->subfield("a") ),
             };
             $i++;
         }
     }
     return $c->render( status => 200, openapi => {data => \@data, count => $i} );
 }
 1;
 file: Koha/REST/V1/Biblios/Geo.pm
 package Koha::REST::V1::Biblios::Geo;
 
 use Mojo::Base 'Mojolicious::Controller';
 use Koha::Biblios;
 
 sub biblio_coordinates {
     my $c = shift->openapi->valid_input or return;
     my $biblionumbers = $c->validation->every_param('bn');
     my @data;
     my $i = 0;
     for my $bn (@$biblionumbers) {
         my $biblio = Koha::Biblios->find( { biblionumber => $bn } );
         my $record = $biblio->metadata->record;
         if ( $record->field('034') ) {
             push @data, {
                 coordinates => [ $record->field('034')->subfield("s"), $record->field('034')->subfield("t") ],
                 title       => sprintf( "<a href='/cgi-bin/koha/opac-detail.pl?biblionumber=%d'>%s</a>",
                                 $bn, $record->field('245')->subfield("a") ),
             };
             $i++;
         }
     }
     return $c->render( status => 200, openapi => {data => \@data, count => $i} );
 }
 1;
 file: Koha/REST/V1/Biblios/Geo.pm
 package Koha::REST::V1::Biblios::Geo;
 
 use Mojo::Base 'Mojolicious::Controller';
 use Koha::Biblios;
 
 sub biblio_coordinates {
     my $c = shift->openapi->valid_input or return;
     my $biblionumbers = $c->validation->every_param('bn');
     my @data;
     my $i = 0;
     for my $bn (@$biblionumbers) {
         my $biblio = Koha::Biblios->find( { biblionumber => $bn } );
         my $record = $biblio->metadata->record;
         if ( $record->field('034') ) {
             push @data, {
                 coordinates => [ $record->field('034')->subfield("s"), $record->field('034')->subfield("t") ],
                 title       => sprintf( "<a href='/cgi-bin/koha/opac-detail.pl?biblionumber=%d'>%s</a>",
                                 $bn, $record->field('245')->subfield("a") ),
             };
             $i++;
         }
     }
     return $c->render( status => 200, openapi => {data => \@data, count => $i} );
 }
 1;
 file: Koha/REST/V1/Biblios/Geo.pm
 package Koha::REST::V1::Biblios::Geo;
 
 use Mojo::Base 'Mojolicious::Controller';
 use Koha::Biblios;
 
 sub biblio_coordinates {
     my $c = shift->openapi->valid_input or return;
     my $biblionumbers = $c->validation->every_param('bn');
     my @data;
     my $i = 0;
     for my $bn (@$biblionumbers) {
         my $biblio = Koha::Biblios->find( { biblionumber => $bn } );
         my $record = $biblio->metadata->record;
         if ( $record->field('034') ) {
             push @data, {
                 coordinates => [ $record->field('034')->subfield("s"), $record->field('034')->subfield("t") ],
                 title       => sprintf( "<a href='/cgi-bin/koha/opac-detail.pl?biblionumber=%d'>%s</a>",
                                 $bn, $record->field('245')->subfield("a") ),
             };
             $i++;
         }
     }
     return $c->render( status => 200, openapi => {data => \@data, count => $i} );
 }
 1;
 file: Koha/REST/V1/Biblios/Geo.pm
 package Koha::REST::V1::Biblios::Geo;
 
 use Mojo::Base 'Mojolicious::Controller';
 use Koha::Biblios;
 
 sub biblio_coordinates {
     my $c = shift->openapi->valid_input or return;
     my $biblionumbers = $c->validation->every_param('bn');
     my @data;
     my $i = 0;
     for my $bn (@$biblionumbers) {
         my $biblio = Koha::Biblios->find( { biblionumber => $bn } );
         my $record = $biblio->metadata->record;
         if ( $record->field('034') ) {
             push @data, {
                 coordinates => [ $record->field('034')->subfield("s"), $record->field('034')->subfield("t") ],
                 title       => sprintf( "<a href='/cgi-bin/koha/opac-detail.pl?biblionumber=%d'>%s</a>",
                                 $bn, $record->field('245')->subfield("a") ),
             };
             $i++;
         }
     }
     return $c->render( status => 200, openapi => {data => \@data, count => $i} );
 }
 1;
 file: Koha/REST/V1/Biblios/Geo.pm
 package Koha::REST::V1::Biblios::Geo;
 
 use Mojo::Base 'Mojolicious::Controller';
 use Koha::Biblios;
 
 sub biblio_coordinates {
     my $c = shift->openapi->valid_input or return;
     my $biblionumbers = $c->validation->every_param('bn');
     my @data;
     my $i = 0;
     for my $bn (@$biblionumbers) {
         my $biblio = Koha::Biblios->find( { biblionumber => $bn } );
         my $record = $biblio->metadata->record;
         if ( $record->field('034') ) {
             push @data, {
                 coordinates => [ $record->field('034')->subfield("s"), $record->field('034')->subfield("t") ],
                 title       => sprintf( "<a href='/cgi-bin/koha/opac-detail.pl?biblionumber=%d'>%s</a>",
                                 $bn, $record->field('245')->subfield("a") ),
             };
             $i++;
         }
     }
     return $c->render( status => 200, openapi => {data => \@data, count => $i} );
 }
 1;
 curl -X GET -H 'Accept: application/json' \
      'http://localhost:8080/api/v1/public/biblios/geo?bn=5&bn=6'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 curl -X GET -H 'Accept: application/json' \
      'http://localhost:8080/api/v1/public/biblios/geo?bn=5&bn=6'

 {
   "count" : 2,
   "data" : [
     {
       "coordinates" : [
         "60.1699",
         "24.9384"
       ],
       "title" : "<a href='/cgi-bin/koha/opac-detail.pl?biblionumber=5'>Perl best practices /</a>"
     },
     {
       "coordinates" : [
         "60.2",
         "24.94"
       ],
       "title" : "<a href='/cgi-bin/koha/opac-detail.pl?biblionumber=6'>X Power Tools</a>"
     }
   ]
 }

Summary

OpenAPI lets you specify / document your API in a machine readable format

that's also readable by humans

There are lots of tools to work with the spec

render docs, generate code, validate input & output, ..

OpenAPI can be a bit complex, but I find it's better to think about the shape of your API using common tools before implementing it

And using OpenAPI and JSON Schema is in fact less work than coming up with your own documentation format

All involved parties (et frontend and backend devs) can think about and even play with the API before you have to actually implement anything

Koha using OpenAPI/Swagger is a big improvement from the old days of CGI scripts

Questions?

Thanks!