Skip to content

Instantly share code, notes, and snippets.

@EvanK
Last active February 29, 2020 20:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save EvanK/c97aeb644254f9c73d10575f0f63dee5 to your computer and use it in GitHub Desktop.
Save EvanK/c97aeb644254f9c73d10575f0f63dee5 to your computer and use it in GitHub Desktop.
Generating static docs for caps-api - like pushing a boulder uphill

Available tooling

Unfortunately, most useful tools for generating static documentation from a swagger spec are no longer maintained or only support swagger's 2.0 format -- we are using the 3.0 format.

The only tool I could find was swagger-codegen, which is built in and requires dependencies in (gag) Java.

Static html

Codegen allows us to generate a single-page static document:

swagger-codegen generate \
-i ./swagger.spec.json \
-l html \
-o ./build/static/ \
-c ./swagger-codegen.json

This does get us a doc page, but without many of the details that would make it useful. In fact, many of the anchor links it includes reference sections that don't even exist on the page.

Needless to say, this was a bust. It may be possible for us to build our own templates (see their source templates), but I don't want to go down that rabbit hole just yet.

Dynamic html

It also lets us generate a "dynamic html" version, which is essentially a bunch of static html/js/css and an express app to serve it:

swagger-codegen generate \
-i ./swagger.spec.json \
-l dynamic-html \
-o build/dynamic/ \
-c ./swagger-codegen.json

We could pull these statically served files out to another directory:

mv ./build/dynamic/docs ./generated

And then serve them as part of our API:

app.use('/static-docs', express.static(path.resolve(__dirname + "/../generated")) );

However, they're not that thorough as (again) a lot of the detail is lost. Additionally, html served statically through the app is antithetical to my purpose here, which was to have offline documentation we could commit to the repo.

Where does that leave us?

Unless we can find (or build) a tool that gives us plain text or markdown, I don't see a way to build useful and comprehensive offline documentation.

Maybe someone else can come back and accomplish this in the future, but for now if you need the docs, you'll have to clone and run the app locally, to use the /docs endpoint.

{"allowUnicodeIdentifiers":true}
{
"openapi": "3.0.0",
"info": {
"title": "Caps API",
"description": "API layer for cap management",
"version": "1.0.0",
"contact": {
"name": "Evan Kaufman",
"email": "ekaufman@redventures.com"
}
},
"servers": [
{
"url": "http://localhost:3000",
"description": "Local development"
}
],
"paths": {
"/healthcheck": {
"get": {
"summary": "Terminus health check",
"description": "Reports app health for terminus wrapper, for the purpose of gracefully spinning down the app in staging or production.\n",
"tags": [
"health"
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"info": {
"type": "string"
},
"details": {
"type": "string"
}
}
},
"example": {
"status": "ok",
"info": "hello world!",
"details": "hello world!"
}
}
}
}
}
}
},
"/": {
"get": {
"summary": "App health check",
"description": "Indicates the app is running.",
"tags": [
"health"
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "string"
},
"example": "\"1\""
}
}
}
}
}
},
"/dsa/{leadId}": {
"get": {
"tags": [
"dsa"
],
"security": [
{
"bearerAuth": []
}
],
"deprecated": true
}
},
"/dsa/guess": {
"post": {
"tags": [
"dsa"
],
"security": [
{
"bearerAuth": []
}
],
"deprecated": true
}
},
"/dsa/{leadId}/increment/{capId}": {
"post": {
"tags": [
"dsa"
],
"security": [
{
"bearerAuth": []
}
],
"deprecated": true
}
},
"/dsa/{leadId}/map/{capId}": {
"post": {
"tags": [
"dsa"
],
"security": [
{
"bearerAuth": []
}
],
"deprecated": true
}
},
"/dsa/map": {
"post": {
"tags": [
"dsa"
],
"security": [
{
"bearerAuth": []
}
],
"deprecated": true
}
},
"/multilead": {
"post": {
"tags": [
"dsa"
],
"security": [
{
"bearerAuth": []
}
],
"deprecated": true
}
},
"/forms": {
"post": {
"tags": [
"dsa"
],
"security": [
{
"bearerAuth": []
}
],
"deprecated": true
}
},
"/general-linkouts": {
"post": {
"tags": [
"linkouts"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "Get caps for given schools and filtering values",
"description": "Given a list of school ids and filtering values, returns ALL of the given schools, each with the first (if any) available cap.\n",
"requestBody": {
"$ref": "#/components/requestBodies/GeneralLinkouts"
},
"responses": {
"$ref": "#/components/responses/GeneralLinkouts"
}
}
},
"/linkouts": {
"post": {
"tags": [
"linkouts"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "Get caps for given programs and filtering values",
"description": "Given a list of provider program ids and filtering values, returns ALL of the given programs, each _containing_ the first (if any) available cap as a property of the program object.\n",
"requestBody": {
"$ref": "#/components/requestBodies/Linkouts"
},
"responses": {
"$ref": "#/components/responses/Linkouts"
}
}
},
"/linkouts/increment/{capId}": {
"post": {
"tags": [
"linkouts"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "Increment given cap",
"description": "Given a cap id, increments the cap once and returns the updated cap object.\n",
"parameters": [
{
"in": "path",
"name": "capId",
"schema": {
"type": "integer",
"required": true,
"description": "Id of an existing cap, typically retrieved from another operation."
},
"example": 1
}
],
"responses": {
"$ref": "#/components/responses/LinkoutsIncrement"
}
}
},
"/search": {
"post": {
"tags": [
"general"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "Get caps for given filtering values",
"description": "Given a list of filtering values, returns a list of matching caps.\n",
"requestBody": {
"$ref": "#/components/requestBodies/Search"
},
"responses": {
"$ref": "#/components/responses/Search"
}
}
}
},
"components": {
"responses": {
"401": {
"description": "Authorization bearer token is missing"
},
"403": {
"description": "Authorization bearer token is incorrect"
},
"Search": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Cap"
}
},
"example": [
{
"id": 99,
"isAvailable": true,
"status": "ENABLED"
}
]
}
}
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
}
},
"GeneralLinkouts": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"school": {
"type": "object",
"id": {
"type": "integer"
},
"slug": {
"type": "string"
},
"active_provider_id": {
"type": "integer"
}
},
"cap": {
"$ref": "#/components/schemas/Cap"
}
}
}
},
"example": [
{
"school": {
"id": 1
},
"cap": {
"id": 99
}
},
{
"school": {
"id": 2
}
}
]
}
}
},
"400": {
"description": "Missing school(s)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HttpError"
},
"example": {
"err": "No schools given"
}
}
}
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
}
},
"Linkouts": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"program": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"cap": {
"$ref": "#/components/schemas/Cap"
}
}
}
}
}
},
"example": [
{
"program": {
"id": 1,
"cap": {
"id": 99
}
}
},
{
"program": {
"id": 2
}
}
]
}
}
},
"400": {
"description": "Missing program(s)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HttpError"
},
"example": {
"err": "No provider programs given"
}
}
}
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
}
},
"LinkoutsIncrement": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"changes": {
"type": "array",
"items": {
"type": "string"
}
},
"cap": {
"$ref": "#/components/schemas/Cap"
}
}
},
"example": {
"changes": [
"incremented 1 cap"
],
"cap": {
"id": 1
}
}
}
}
},
"400": {
"description": "Missing cap id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HttpError"
},
"example": {
"err": "No cap id provided"
}
}
}
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
},
"422": {
"description": "Invalid cap id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HttpError"
},
"example": {
"err": "No valid cap found"
}
}
}
}
}
},
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {
"Cap": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"providerId": {
"type": "integer"
},
"isLinkout": {
"type": "boolean"
},
"overdeliver": {
"type": "boolean"
},
"isCpc": {
"type": "boolean"
},
"status": {
"$ref": "#/components/schemas/Status"
},
"trafficSource": {
"$ref": "#/components/schemas/TrafficSource"
},
"isGeneral": {
"type": "boolean"
},
"countries": {
"type": "array",
"items": {
"type": "integer"
}
},
"states": {
"type": "array",
"items": {
"type": "integer"
}
},
"publishers": {
"type": "array",
"items": {
"type": "integer"
}
},
"programs": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"TrafficSource": {
"type": "string",
"examples": [
"all",
"organic",
"paid",
"search",
"social"
],
"description": "As the property of a cap, indicates one or more traffic sources (a.k.a campaign types) that an individual cap can accept. For example, a cap with `\"organic social\"` is capable of handling both organic and paid social traffic.\n> In this context, `\"all\"` indicates the cap accepts _any_ source of traffic.\n\nAs the input of an API operation, indicates to retrieve caps capable of handling one or more of the given sources. For example, an input of `\"organic social\"` will return caps capable of handling organic _or_ paid social traffic, including those capable of handling both.\n> In this context, `\"all\"` is a special value that means \"return all caps regardless of the source(s) they may accept\", and is equivalent to passing `null`.\n"
},
"Status": {
"type": "string",
"enum": [
"ENABLED",
"PAUSED",
"ARCHIVED"
],
"description": "The operational status of a given cap:\n- `ENABLED`: Cap is active and accepting traffic\n- `PAUSED`: Cap is temporarily deactivated\n- `ARCHIVED`: Cap has been deactivated for the foreseeable future\n"
},
"HttpError": {
"type": "object",
"properties": {
"err": {
"type": "string"
}
}
}
},
"requestBodies": {
"Search": {
"description": null,
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"isAvailable": {
"type": "boolean",
"nullable": true
},
"provider": {
"type": "integer",
"nullable": true
},
"isGeneral": {
"type": "boolean",
"default": false
},
"isLinkout": {
"type": "boolean",
"nullable": true
},
"trafficSource": {
"type": "string",
"nullable": true
},
"status": {
"type": "string",
"nullable": true
}
}
},
"example": {
"isAvailable": true,
"status": "ENABLED"
}
}
}
},
"GeneralLinkouts": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"schoolIds": {
"type": "array",
"items": {
"type": "integer"
}
},
"trafficSource": {
"$ref": "#/components/schemas/TrafficSource"
},
"military": {
"type": "boolean"
},
"state": {
"type": "integer"
},
"country": {
"type": "integer"
},
"publisher": {
"type": "integer"
},
"multileadThreshold": {
"type": "integer",
"deprecated": true
}
}
},
"example": {
"schoolIds": [
1,
2
],
"trafficSource": "all",
"country": 1,
"state": 44,
"military": 1
}
}
}
},
"Linkouts": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"providerPrograms": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"provider_id": {
"type": "integer"
}
}
}
},
"product": {
"type": "string",
"nullable": true,
"enum": [
"exclusive"
]
},
"trafficSource": {
"$ref": "#/components/schemas/TrafficSource"
},
"military": {
"type": "boolean"
},
"state": {
"type": "integer"
},
"country": {
"type": "integer"
},
"publisher": {
"type": "integer"
},
"multileadThreshold": {
"type": "integer",
"deprecated": true
}
}
},
"example": {
"providerPrograms": [
{
"id": 1,
"provider_id": 10
},
{
"id": 2,
"provider_id": 11
}
],
"trafficSource": "all",
"country": 1,
"state": 44,
"military": 1
}
}
}
}
}
},
"tags": [
{
"name": "health",
"description": "Health check operations"
},
{
"name": "general",
"description": "General purpose cap operations"
},
{
"name": "linkouts",
"description": "Operations related to the Linkouts (a.k.a. Exclusive) product"
},
{
"name": "dsa",
"description": "Operations related to the DSA (a.k.a. Select) product ⚰️ RIP ⚰️"
}
]
}
diff --git a/src/app.js b/src/app.js
index f5ab0fc..9288635 100644
--- a/src/app.js
+++ b/src/app.js
@@ -46,6 +46,7 @@ app.all('/', (req, res) => res.status(200).json('1'))
const { NODE_ENV, PORT = 3000 } = process.env
if (NODE_ENV === "development") {
+ const { writeFileSync } = require("fs")
const swaggerJsdoc = require("swagger-jsdoc")
const swaggerUi = require("swagger-ui-express")
@@ -85,6 +86,10 @@ if (NODE_ENV === "development") {
"./src/handlers/*.js",
]
});
+ writeFileSync(
+ `${__dirname}/../swagger.spec.json`,
+ JSON.stringify(specs, null, 2)
+ )
app.use("/docs", swaggerUi.serve);
app.get(
"/docs",
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment