GLobbify OPENapi: Glob folder structures into an OpenAPI definition and API driver.
The basic idea is that you create a folder structure to represent the final OpenAPI object, with some very light sugar, and use that to both generate the OpenAPI definition file and drive the API.
You can also merge multiple distinct folders together, into a single definition and driver, allowing API endpoint definitions to be published as discrete, reusable libraries.
The usual way:
npm install glopen
As part of the build process, for a single folder structure, simply do:
glopen --dir=./path/to/api/folder --out=./generated-file.js
(Use the -w
or --watch
flags to watch for changes and rebuild when any are detected, for easier development.)
The --out
flag is optional, if not set the code will print, so you could also do:
glopen --dir=./path/to/api/folder > ./generated-file.js
For multiple merged folder structures, you can use multiple --json
parameters, with each being a JSON object, or an array of objects:
glopen --json='{ "dir": "./path1", "api": "/api/v1" }' \
--json='[ { "dir": "./path1", "api": "/api/v1" }, { "dir": "./path2", "api": "/api/v2" } ]' \
--out=./generated-file.js
To use it in code, pass in { merge: Array<Part> }
:
import { glopen } from 'glopen'
import { writeFile } from 'node:fs/promises'
const code = await glopen({
merge: [
{
dir: './path/to/api/folder',
api: '/api/v1/tasks',
ext: '@'
}
]
})
await writeFile('./generated-file.js', code, 'utf8')
You can also use the --config
(or -c
alias) to point to a JavaScript file that exports a config object:
export default {
merge: [{
dir: './path/to/api/folder',
api: '/api/v1/tasks',
ext: '@'
}],
output: './generated-file.js'
}
Whether used in code, or in any of the modes, each API "part" has these parameters:
dir
required - The path to the API folder.api
optional - The API path to prefix to the folder, e.g. if the folder is/tasks
andprefix
is/api/v1
the API path becomes/api/v1/tasks
.ext
optional - The extension prefix used for auto-globbing, (Default:@
, e.g.get.@.js
)
For example, given this folder structure:
/demo
/tasks
/get.@.js
If, at the root, you used glopen --dir=./demo
the OpenAPI operation would be GET /tasks
.
However, if you used glopen --dir=./demo --api=/v1
the OpenAPI operation would be GET /v1/tasks
.
When used in code, the input is an object with a merge
property, which is an ordered array of "part" objects.
const code = await glopen({
merge: [
{
dir: './path/to/users',
api: '/api/v2/users',
ext: '@'
}
],
})
The different modes (single, json, and config) are not mixable, you must pick one or the other.
In single mode, a single "part" is passed in as CLI args:
glopen --api=./path/to/users \
--prefix=/api/v2/users \
--suffix=@
In JSON mode, one or more "parts" are passed in as JSON strings, either as objects, arrays, or mixed:
glopen --json='{ "dir": "./path/to/users", "api": "/api/v2/users", "ext": "@" }' \
--json='[ { "dir": "./path/to/tasks", "api": "/api/v2/tasks", "ext": "$" }, { "dir": "./path/to/cars", "api": "/api/v1/cars", "ext": "@" } ]'
The order of all JSON inputs is preserved, so the above example would be the same as:
await glopen({
merge: [
{
dir: './path/to/users',
api: '/api/v2/users',
ext: '@'
},
{
dir: './path/to/tasks',
api: '/api/v2/tasks',
ext: '$'
},
{
dir: './path/to/cars',
api: '/api/v1/cars',
ext: '$'
},
]
})
Calling glopen
with the -c
or --config
option will import the file specified, or glopen.config.js
by default:
glopen -c # uses './glopen.config.js'
# or
glopen -c ./path/to/config.js
The config file must export a default object containing the following properties:
merge: Array<Part>
required - An array of "parts", e.g.dir
,api
,ext
.output: String
optional - The file path to write to.
To be clear, all paths, components, and so on, will all be merged on top of each other, first to last. In other words, if there are overlapping paths, models, etc., the winning one will be the last.
This copies the Object.assign
order, e.g.:
Object.assign({}, { a: 1 }, { a: 2 })
// { a: 2 }
For example, an OpenAPI definition with a single path might look like:
{
"paths": {
"/api/v1/tasks/{taskId}": {
"get": {
"description": "Get a single task."
}
}
}
}
That translates to the folder+file structure:
/paths
/api
/v1
/tasks
/{taskId}
/get.@.js
Note that the .@.js
suffix is configurable, but it means that we can auto-glob the files together (the sugar) and put other files next to it, e.g.:
/paths
/api
/v1
/tasks
/{taskId}
/get.@.js
/get.test.js
/some-utils.js
Inside each file, you just export named constants that match the OpenAPI property names, for example:
// file: /paths/api/v1/tasks/{taskId}/get.@.js
export const summary = 'Get a single task.'
export const tags = [ 'task' ]
For methods, you would export a default function as a request handler. Since this generator is simply importing and exporting, the argument parameters can be whatever you'd like. Here we're using the normal Express-like async (request, response)
signature, but you can use any signature you like:
// same file
export default async (request, response) => {
response.end(`Task ID: ${request.params.taskId}`)
}
What comes out of the generator is a single JS file that imports and exports the folder+file tree into something useful for generating the OpenAPI JSON object, as well as for passing into API frameworks like Polka, Express, and so on:
import GENERATED_ID_handler, * as GENERATED_ID from './paths/api/v1/tasks/{taskId}/get.@.js'
export const definition = {
paths: {
'/api/v1/tasks/{taskId}': {
get: {
...GENERATED_ID,
operationId: 'GENERATED_ID_get',
},
},
}
}
export const routes = [
{
handler: GENERATED_ID_handler,
exports: GENERATED_ID,
method: 'get',
path: '/api/v1/tasks/{taskId}',
// Because the `:`` prefix is so common, it is offered as an alternate
// to the OpenAPI path syntax.
pathAlt: '/api/v1/tasks/:taskId',
operationId: 'GENERATED_ID_get',
},
]
The special exception to a named file matching an OpenAPI object is that you still need to define some properties at the root of an object. For example, the "Path Item Object" has a description
property.
To resolve this, the underscore character is reserved as a file name. Simply place a file named _.@.js
in the folder, and those properties will be merged, e.g. for this structure:
/paths
/api
/v1
/tasks
/{taskId}
/_.@.js
/get.@.js
The _.@.js
file would be responsible for the "Path Item Object" properties, such as
the path parameter {taskId}
definition. This looks the same as the other files:
export const parameters = [
{
name: 'taskId',
in: 'path',
required: true,
schema: {
type: 'string'
}
}
]
The generated output would expand the exported properties from _.@.js
to the Path Item Object:
import * as GENERATED_ID0 from './paths/api/v1/tasks/{taskId}/_.@.js'
import GENERATED_ID1_handler, * as GENERATED_ID1 from './paths/api/v1/tasks/{taskId}/get.@.js'
export const definition = {
paths: {
'/api/v1/tasks/{taskId}': {
...GENERATED_ID0,
get: {
...GENERATED_ID1,
operationId: 'GENERATED_ID_get',
},
},
}
}
The generator creates a file that exports two named properties:
definition
Object - The object containing the fully constructed OpenAPI definition.routes
Array - A list of all routes, including the handler.To get the OpenAPI JSON, you simply import and stringify:
import { definition } from './generated-file.js' console.log(JSON.stringify(definition, null, 2))
Would output:
{ "paths": { "/api/v1/tasks/{taskId}": { "parameters": [ { "name": "taskId", "in": "path", "required": true, "schema": { "type": "string" } } ], "get": { "summary": "Get a single task.", "tags": [ "task" ] } } } }
To use the generated file in an API framework, like Polka, import the
routes
property:import polka from 'polka' import { routes } from './generated-file.js' const api = polka() routes.forEach(({ handler, exports, method, path, pathAlt, operationId }) => { console.log(' - ', method.toUpperCase(), polkaPath, '\n ', exports.summary) api[method](pathAlt, handler) }) api.listen(3000, () => { console.log('API running on port 3000, try opening: http://localhost:3000/api/v1/tasks/9001') })
Each route array element has the following properties:
handler
- Thedefault
export of the file.exports
- Every named export of the file.method
- The lower-cased method, which comes from the filename, e.g.get.@.js
becomesget
.path
- The full OpenAPI path string, which comes from the folder paths.pathAlt
- The:
prefixed path syntax is so common, it is provided for your convenience.operationId
- The generated identifier of the path+method.
You would use those to do things like secure the route, validate input against schemas, and so on.
It is very convenient to be able to write longer descriptions in separate markdown files, so that you get the syntax highlighting, previews, etc. that you wouldn't get if you put it directly in the JavaScript file.
In other words, this isn't a very developer nice experience:
export const description = ` This really long string will work just fine, so use this if you like. Hoever, in most IDEs you won't get markdown syntax highlighting, and \`escaping\` the template literals can get annoying. `
Modern bundlers support importing string, so you could definitely do this:
export { default as description } from './description.md'
One of the goals of this project is to output a generated file that doesn't require further bundling to function. To that end, if you name a markdown file appropriately, it'll get brought in as a string and exported, e.g.
get.description.@.md
will become:// file: generated-file.js const GENERATED_ID_description = "The text gets placed here, since it can't be imported."
The naming convention is simply
FILENAME.PROPERTY.SUFFIX.md
:FILENAME
- The filename to connect to, e.g.get
or_
.PROPERTY
- The property name to connect to, e.g.description
. (Note: the generator doesn't support nested text, sorry.)SUFFIX
- By default it's@
, but that's configurable.
These are handled the same way, so instead of
_.@.js
exporting thetaskId
parameter definition, it could export the parameter definition as a schema reference:export const parameters = [ { $ref: '#/components/parameters/taskId' } ]
The generator will check that all
$ref
references that use a#/
prefix are resolvable in the final OpenAPI object, and throw an error if not.This software and all example code are published to the public domain using the Very Open License.