Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/openapi-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ jobs:

- name: Setup bundler
run: |
npm install -g @redocly/cli@1.0.0-beta.100
npm install -g @redocly/cli@2.31.5
mkdir bundled/

- name: Bundle
run: redocly bundle openapi/spec/openapi.yaml -o bundled/openapi.yaml
- name: Bundle customer documentation
working-directory: openapi
run: |
redocly bundle community-customer@v1 -o ../bundled/community-openapi.yaml
redocly bundle cloud-customer@v1 -o ../bundled/cloud-openapi.yaml
redocly bundle enterprise-customer@v1 -o ../bundled/enterprise-openapi.yaml

- name: Upload artifacts
uses: actions/upload-artifact@v7
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/openapi-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@ jobs:
run: prettier -c openapi/spec/**/*.yaml

- name: Setup Redocly
run: npm install -g @redocly/cli@1.0.0-beta.100
run: npm install -g @redocly/cli@2.31.5

- name: Run Redocly lint
run: redocly lint openapi/spec/openapi.yaml
working-directory: openapi
run: redocly lint spec/openapi.yaml spec/community-openapi.yaml spec/cloud-openapi.yaml spec/enterprise-openapi.yaml

- name: Bundle customer specs (exercise the customer-filter plugin)
working-directory: openapi
run: |
for edition in community cloud enterprise; do
redocly bundle "${edition}-customer@v1" -o "/tmp/${edition}-customer.json"
done
2 changes: 1 addition & 1 deletion .github/workflows/validate-ui-react.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
if: steps.filter.outputs[matrix.app] == 'true'
working-directory: ui-react
run: |
npx @redocly/cli@1.0.0-beta.100 bundle ../openapi/spec/openapi.yaml -o /tmp/openapi.json --force
npx @redocly/cli@2.31.5 bundle ../openapi/spec/openapi.yaml -o /tmp/openapi.json --force
OPENAPI_SPEC_PATH=/tmp/openapi.json npm run generate -w ${{ matrix.workspace }}

- name: Unit test [${{ matrix.app }}]
Expand Down
1 change: 1 addition & 0 deletions openapi/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
bundled/
.env
openapi.json
customer.json
2 changes: 1 addition & 1 deletion openapi/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ RUN apk add --no-cache openssl nodejs npm openjdk11-jre git

RUN npm install -g @openapitools/openapi-generator-cli

RUN npm install -g @redocly/cli@1.0.0-beta.100
RUN npm install -g @redocly/cli@2.31.5

RUN npm install -g @stoplight/prism-cli@4.6.1

Expand Down
20 changes: 19 additions & 1 deletion openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,25 @@ OpenAPI](http://localhost/openapi/) in any OpenAPI file after a page reload.
Its usage is simple too, you just need `up` the `shellhub-io/shellhub` containers in development
mode and access the URL.

- Access the URL [http://localhost/openapi/preview](http://localhost/openapi) to check the preview.
- Access the URL [http://localhost/openapi/](http://localhost/openapi/) to check the preview.

### Full spec vs customer docs

There are two builds of every edition spec, from the same source:

- **Full spec** — the internal source of truth, documenting every route (public,
internal, and admin). It backs the frontend client generation and the
dev-mode response validator. This is what the preview above serves.
- **Customer docs** — the published, customer-facing documentation. A
`drop-non-customer` decorator (`plugins/customer-filter.js`) keeps only the
namespace-scoped, API-key usable surface: it drops `/admin` and `/internal`
routes, operations that do not accept an API key, and anything flagged
`x-internal: true`; it also strips the `jwt` scheme so the docs show api-key
only. These are the `*-customer` API entries in `redocly.yaml`, published on
release by `openapi-cd.yml`.

Preview the customer docs locally at
[http://localhost/openapi/customer.html](http://localhost/openapi/customer.html).

### Lint

Expand Down
10 changes: 10 additions & 0 deletions openapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ func main() {
log.Fatalf("error: failed to bundle the openapi spec: %v", err)
}

// Also bundle the customer-facing (filtered) spec so it can be previewed at
// /openapi/customer.html. This applies the drop-non-customer decorator,
// leaving only the namespace-scoped, api-key usable surface. A failure here
// must not stop the server: the full spec above is what the frontend codegen
// and the response validator depend on.
customerAPI := edition + "-customer@v1"
if err := exec.Command("redocly", "bundle", customerAPI, "-o", "static/customer.json").Run(); err != nil { //nolint:gosec
log.Printf("warning: failed to bundle the customer OpenAPI preview (%s): %v", customerAPI, err)
}

mux := http.NewServeMux()

// NOTE: Gateway proxy to serve the OpenAPI spec and the Redoc UI. directly on the /openapi path.
Expand Down
211 changes: 211 additions & 0 deletions openapi/plugins/customer-filter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Redocly decorator that keeps only the customer-facing surface of the API.
//
// An operation is kept when it is usable by a customer integration, which means
// all of:
// 1. its path is not under /admin or /internal (those surfaces are never
// customer facing), and
// 2. it accepts the `api-key` security scheme (api-key auth is namespace
// related and not tied to a user), and
// 3. it is not explicitly flagged with `x-internal: true`.
//
// Everything else (admin, internal, login/auth, account, billing, MFA, user and
// the few api-key accepting but account level routes) is dropped. Path items
// left without any operation are removed too.
//
// Operations are removed at the operation level, because deleting a whole path
// item from its parent does not stick for paths composed through a
// `paths: $ref` import (cloud and enterprise specs). Empty path items are then
// pruned in the Root visitor, which runs on the fully assembled document.
//
// The `x-internal` flag is an explicit override for operations that accept an
// api-key but are still not part of the customer integration surface (for
// example create/list namespace, leave namespace, list api keys, member
// invitations).

// Home page (info.description) for the customer docs. The full spec keeps its
// own internal-facing description; this one is injected only into the filtered
// build.
const CUSTOMER_DESCRIPTION = `Programmatic access to your namespace: devices, sessions, SSH public keys,
tags, firewall rules, and more.

## Base URL

Endpoints are served under \`/api\` on your ShellHub server. On ShellHub Cloud
the base URL is \`https://cloud.shellhub.io/api\`.

## Authentication

Send your API key in the \`X-API-KEY\` header:

\`\`\`
curl https://cloud.shellhub.io/api/devices -H "X-API-KEY: <your-key>"
\`\`\`

An API key belongs to a single namespace and is not tied to a user. Create one
in the console under **Namespace → API Keys**. Every endpoint operates within
the key's namespace, so the namespace scope is implicit throughout this
reference.

## Pagination

List endpoints accept \`page\` and \`per_page\` query parameters and return the
total item count in the \`X-Total-Count\` response header.

## Errors

Errors use standard HTTP status codes. \`401\` means the API key is missing or
invalid; \`403\` means the key's role does not allow the operation.`;

const HTTP_METHODS = [
'get',
'put',
'post',
'delete',
'options',
'head',
'patch',
'trace',
];

const INTERNAL_PREFIXES = ['/admin', '/internal'];

// Edition and audience markers used as tags across the specs. They are not
// resources, so they are stripped from the customer docs to leave a clean
// resource-based grouping (devices, sessions, rules, ...).
const NON_RESOURCE_TAGS = new Set([
'community',
'cloud',
'enterprise',
'internal',
'external',
]);

function operationAcceptsApiKey(operation) {
const security = operation.security;

return (
Array.isArray(security) &&
security.some(
(requirement) =>
requirement &&
Object.prototype.hasOwnProperty.call(requirement, 'api-key'),
)
);
}

function isCustomerOperation(operation) {
return operation['x-internal'] !== true && operationAcceptsApiKey(operation);
}

function hasAnyOperation(pathItem) {
return HTTP_METHODS.some((method) => pathItem[method] !== undefined);
}

function DropNonCustomer() {
return {
PathItem: {
enter(pathItem, ctx) {
const path = ctx.key;
const isInternalPath =
typeof path === 'string' &&
INTERNAL_PREFIXES.some((prefix) => path.startsWith(prefix));

for (const method of HTTP_METHODS) {
const operation = pathItem[method];

if (operation === undefined) {
continue;
}

if (isInternalPath || !isCustomerOperation(operation)) {
delete pathItem[method];

continue;
}

// Kept operation: clean it up for the customer docs.
if (Array.isArray(operation.tags)) {
operation.tags = operation.tags.filter(
(tag) => !NON_RESOURCE_TAGS.has(tag),
);
}

// The customer docs are api-key only, so drop the jwt alternative
// from the security requirements (it stays in the full spec).
if (Array.isArray(operation.security)) {
operation.security = operation.security.filter(
(requirement) =>
requirement &&
Object.prototype.hasOwnProperty.call(requirement, 'api-key'),
);
}
}
},
},
Root: {
leave(root) {
if (!root.paths) {
return;
}

for (const path of Object.keys(root.paths)) {
if (!hasAnyOperation(root.paths[path])) {
delete root.paths[path];
}
}

// Drop root tags that no surviving operation references, so the
// rendered docs do not show empty tag groups.
if (Array.isArray(root.tags)) {
const usedTags = new Set();

for (const pathItem of Object.values(root.paths)) {
for (const method of HTTP_METHODS) {
const operation = pathItem[method];

if (operation && Array.isArray(operation.tags)) {
operation.tags.forEach((tag) => usedTags.add(tag));
}
}
}

const seenTags = new Set();
root.tags = root.tags.filter((tag) => {
if (!usedTags.has(tag.name) || seenTags.has(tag.name)) {
return false;
}

seenTags.add(tag.name);

return true;
});
}

// The customer docs authenticate only with api-key, so drop the jwt
// security scheme. The full spec keeps it.
if (root.components && root.components.securitySchemes) {
delete root.components.securitySchemes.jwt;
}

// Replace the home page with the customer-facing description. The full
// spec keeps its internal-facing one.
if (root.info) {
root.info.description = CUSTOMER_DESCRIPTION;
}
},
},
};
}

function customerFilterPlugin() {
return {
id: 'customer-filter',
decorators: {
oas3: {
'drop-non-customer': DropNonCustomer,
},
},
};
}

module.exports = customerFilterPlugin;
40 changes: 24 additions & 16 deletions openapi/redocly.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
# This is the Redocly configuration file, and it is used by Redocly CLI and other Redocly apps to control their behavior
# — from the strictness of the lint command, to how Redocly renders your docs.
#
# - Workflows uses it in the API registry to manage your APIs and control advanced features like region and link resolution.
# - Workflows and on-premise tools use it to apply your features and theme when building API docs.
# - Redocly's VS Code extension uses it for linting criteria, to apply custom settings for live documentation previews,
# and to identify the path API definition root files.
#
# https://redocly.com/docs/cli/configuration/
organization: shellhub
# Redocly configuration. Controls linting and how the OpenAPI specs are bundled
# and rendered. https://redocly.com/docs/cli/configuration/

# This extends the recommended configuration values.
# https://redocly.com/docs/cli/rules/#recommended-config
Expand All @@ -17,17 +9,33 @@ extends:
rules:
no-ambiguous-paths: off

plugins:
- ./plugins/customer-filter.js
Comment thread
gustavosbarreto marked this conversation as resolved.

apis:
# Full specs: the internal source of truth, consumed by the frontend codegen
# and the dev-mode response validator. No filtering applied.
community@v1:
root: ./spec/community-openapi.yaml
cloud@v1:
root: ./spec/cloud-openapi.yaml
enterprise@v1:
root: ./spec/enterprise-openapi.yaml

features.openapi:
schemaExpansionLevel: 2
generateCodeSamples:
languages:
- lang: curl
- lang: Go
# Customer specs: the published documentation. The drop-non-customer decorator
# keeps only the namespace-scoped, api-key usable surface.
community-customer@v1:
root: ./spec/community-openapi.yaml
decorators:
customer-filter/drop-non-customer: on
remove-unused-components: on
cloud-customer@v1:
root: ./spec/cloud-openapi.yaml
decorators:
customer-filter/drop-non-customer: on
remove-unused-components: on
enterprise-customer@v1:
root: ./spec/enterprise-openapi.yaml
decorators:
customer-filter/drop-non-customer: on
remove-unused-components: on
Loading
Loading