Skip to content

Commit 913fdc3

Browse files
docs: document the v1 API + add /api/v1/index.json discovery manifest
Two changes that make the v1 surface usable without first parsing skills.json: 1. README 'Public API (v1)' section lists every endpoint, the URL pattern (with {namespace}/{slug} placeholders), the stability contract, and copy-pasteable embed examples for both inline SVG and shields.io endpoint mode. Forks get the URL pattern unchanged: only the host swaps. 2. /api/v1/index.json is now generated alongside the rest of the tree by build-api-v1 (and write_api_v1). It carries the schema version, URL templates, and the current (namespace, slug) list - a single fetch a third-party consumer can use to learn the entire API surface and iterate over current skills without grabbing the heavier index. Tests: 54 pytest (added two for build_v1_index covering history-on / history-off variants and URL-template shape), ruff clean, markdownlint clean. Real-payload smoke run now writes 18 files (was 17) under api/v1.
1 parent 04a8703 commit 913fdc3

3 files changed

Lines changed: 149 additions & 4 deletions

File tree

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,49 @@ Stable URLs, no auth required:
3939
+ Schema: `https://coder.github.io/coder-skill-scanner/schema.json` (v1)
4040
+ Per-scan history (JSON): `https://coder.github.io/coder-skill-scanner/history/index.json`
4141

42+
## Public API (v1)
43+
44+
Under `/api/v1/`, every URL is constructible from `(namespace, slug)` alone
45+
— no lookup against the index is required to render a badge or read a
46+
single skill. Field names and URL shapes are committed to the `v1`
47+
stability contract; breaking changes move to a `v2` prefix.
48+
49+
| URL | Shape | Use |
50+
| --- | --- | --- |
51+
| `/api/v1/index.json` | discovery manifest: URL templates + current `(ns, slug)` pairs | bootstrap a third-party consumer |
52+
| `/api/v1/skills.json` | compact index of every skill | listing / cache warmer |
53+
| `/api/v1/skills/<ns>/<slug>.json` | per-skill detail (reasons, findings, `links` block) | per-skill consumer |
54+
| `/api/v1/skills/<ns>/<slug>/badge/status.json` | shields.io endpoint payload | `img.shields.io/endpoint?url=...` |
55+
| `/api/v1/skills/<ns>/<slug>/badge/status.svg` | inline SVG | direct embed |
56+
| `/api/v1/skills/<ns>/<slug>/badge/score.json` | shields.io endpoint payload | same |
57+
| `/api/v1/skills/<ns>/<slug>/badge/score.svg` | inline SVG | direct embed |
58+
| `/api/v1/history.json` | reshape of history with absolute report URLs | history consumer |
59+
60+
Two badges per skill:
61+
62+
+ **`status`** — the categorical scan outcome (`clean`, `suspicious`,
63+
`malicious`, `unknown`). Colour follows the verdict 1:1.
64+
+ **`score`** — the numeric SkillSpector risk score (`0/100``100/100`).
65+
Colour is banded at the same 21 / 51 / 81 cutoffs the verdict policy
66+
uses.
67+
68+
Embed a status badge in a README:
69+
70+
```markdown
71+
![skill scan](https://coder.github.io/coder-skill-scanner/api/v1/skills/coder/setup/badge/status.svg)
72+
```
73+
74+
Or via shields.io if you want their renderer:
75+
76+
```markdown
77+
![skill scan](https://img.shields.io/endpoint?url=https://coder.github.io/coder-skill-scanner/api/v1/skills/coder/setup/badge/status.json)
78+
```
79+
80+
For a fork, swap the host: `https://<owner>.github.io/<repo>/api/v1/...`.
81+
The scanner derives the public base URL from `$GITHUB_REPOSITORY` at
82+
publish time, so the same URL pattern is correct for any fork without
83+
config changes.
84+
4285
## Running locally
4386

4487
Requires Python 3.12+, Node 22+ (via `mise`), pnpm, and `git`.

scanner/api.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
33
The v1 contract:
44
5+
- ``api/v1/index.json`` Discovery manifest. Lists the URL
6+
templates (with ``{namespace}`` and
7+
``{slug}`` placeholders) needed to
8+
address every other endpoint, plus the
9+
current ``(namespace, slug)`` pairs.
10+
Bootstrap entry point for third-party
11+
consumers - no other fetch required to
12+
learn the URL conventions.
513
- ``api/v1/skills.json`` Compact index of every skill in the most
614
recent scan: namespace, slug, verdict,
715
risk_score, source_repo, source_sha,
@@ -176,6 +184,44 @@ def build_history_index(
176184
}
177185

178186

187+
def build_v1_index(
188+
report: dict[str, Any],
189+
*,
190+
public_base_url: str,
191+
has_history: bool,
192+
generated_at: str | None = None,
193+
) -> dict[str, Any]:
194+
"""Build the ``api/v1/index.json`` discovery manifest.
195+
196+
The manifest lists the URL templates a third-party consumer needs to
197+
address every endpoint without first parsing ``skills.json``, plus the
198+
current ``(namespace, slug)`` pairs so a programmatic consumer can iterate
199+
without a second fetch. Field shapes are part of the v1 contract.
200+
"""
201+
root = public_base_url.rstrip("/")
202+
api_root = f"{root}/api/v1"
203+
skills = sorted(
204+
({"namespace": s["namespace"], "slug": s["slug"]} for s in report.get("skills", [])),
205+
key=lambda s: (s["namespace"], s["slug"]),
206+
)
207+
urls = {
208+
"skills_index": f"{api_root}/skills.json",
209+
"skill_detail": f"{api_root}/skills/{{namespace}}/{{slug}}.json",
210+
"status_badge_json": f"{api_root}/skills/{{namespace}}/{{slug}}/badge/status.json",
211+
"status_badge_svg": f"{api_root}/skills/{{namespace}}/{{slug}}/badge/status.svg",
212+
"score_badge_json": f"{api_root}/skills/{{namespace}}/{{slug}}/badge/score.json",
213+
"score_badge_svg": f"{api_root}/skills/{{namespace}}/{{slug}}/badge/score.svg",
214+
}
215+
if has_history:
216+
urls["history"] = f"{api_root}/history.json"
217+
return {
218+
"schema_version": API_SCHEMA_VERSION,
219+
"generated_at": generated_at or report.get("generated_at", ""),
220+
"urls": urls,
221+
"skills": skills,
222+
}
223+
224+
179225
def write_api_v1(
180226
report: dict[str, Any],
181227
*,
@@ -195,6 +241,15 @@ def write_api_v1(
195241
skills_index_path.write_text(json.dumps(skills_index, indent=2) + "\n", encoding="utf-8")
196242
written.append(skills_index_path)
197243

244+
discovery = build_v1_index(
245+
report,
246+
public_base_url=public_base_url,
247+
has_history=history_manifest is not None,
248+
)
249+
discovery_path = output_dir / "index.json"
250+
discovery_path.write_text(json.dumps(discovery, indent=2) + "\n", encoding="utf-8")
251+
written.append(discovery_path)
252+
198253
for skill in report.get("skills", []):
199254
ns = _check_safe_segment("namespace", skill["namespace"])
200255
slug = _check_safe_segment("slug", skill["slug"])

tests/test_api.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,9 @@ def test_write_api_v1_writes_full_tree(tmp_path: Path):
133133
history_manifest={"entries": []},
134134
)
135135

136-
# 1 (skills.json) + 3 skills * (1 detail + 4 badge files) + 1 (history.json) = 17.
137-
assert len(written) == 17
136+
# 1 (skills.json) + 1 (index.json) + 3 skills * (1 detail + 4 badge files) +
137+
# 1 (history.json) = 18.
138+
assert len(written) == 18
138139

139140
# Index validates as parseable JSON, has the right schema_version.
140141
idx = json.loads((tmp_path / "skills.json").read_text())
@@ -167,8 +168,8 @@ def test_write_api_v1_skips_history_when_not_provided(tmp_path: Path):
167168
written = api.write_api_v1(
168169
report, output_dir=tmp_path, public_base_url="https://example.com/scanner"
169170
)
170-
# 1 (skills.json) + 1 * (1 detail + 4 badge files) = 6
171-
assert len(written) == 6
171+
# 1 (skills.json) + 1 (index.json) + 1 * (1 detail + 4 badge files) = 7
172+
assert len(written) == 7
172173
assert not (tmp_path / "history.json").exists()
173174

174175

@@ -188,3 +189,49 @@ def test_write_api_v1_rejects_path_traversal_slug(tmp_path: Path):
188189
api.write_api_v1(
189190
report, output_dir=tmp_path, public_base_url="https://example.com/x"
190191
)
192+
193+
194+
def test_build_v1_index_lists_url_templates_and_skills():
195+
"""The discovery manifest at /api/v1/index.json must let a third-party
196+
consumer address every endpoint without first fetching skills.json."""
197+
report = _report(
198+
[
199+
_skill("templates"),
200+
_skill("modules"),
201+
_skill("setup", verdict="malicious", risk=100),
202+
]
203+
)
204+
manifest = api.build_v1_index(
205+
report,
206+
public_base_url="https://example.com/scanner",
207+
has_history=True,
208+
)
209+
210+
assert manifest["schema_version"] == 1
211+
# URL templates use {namespace} and {slug} placeholders, anchored at the
212+
# caller-supplied base; consumers can substitute without knowing layout.
213+
urls = manifest["urls"]
214+
assert urls["skills_index"] == "https://example.com/scanner/api/v1/skills.json"
215+
assert urls["skill_detail"].endswith("/api/v1/skills/{namespace}/{slug}.json")
216+
assert urls["status_badge_svg"].endswith(
217+
"/api/v1/skills/{namespace}/{slug}/badge/status.svg"
218+
)
219+
assert urls["score_badge_json"].endswith(
220+
"/api/v1/skills/{namespace}/{slug}/badge/score.json"
221+
)
222+
# history URL is included only when a history manifest was supplied.
223+
assert urls["history"].endswith("/api/v1/history.json")
224+
# Skill list is sorted and contains only namespace/slug pairs.
225+
assert manifest["skills"] == [
226+
{"namespace": "coder", "slug": "modules"},
227+
{"namespace": "coder", "slug": "setup"},
228+
{"namespace": "coder", "slug": "templates"},
229+
]
230+
231+
232+
def test_build_v1_index_omits_history_url_when_no_history():
233+
report = _report([_skill("modules")])
234+
manifest = api.build_v1_index(
235+
report, public_base_url="https://example.com/scanner", has_history=False
236+
)
237+
assert "history" not in manifest["urls"]

0 commit comments

Comments
 (0)