@nasebanal/cli 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -24
- package/dist/reports/locust.js +26 -7
- package/dist/reports/specmatic.js +69 -0
- package/dist/reports/upload.js +119 -19
- package/package.json +1 -1
- package/specs/account.yaml +134 -1
- package/specs/recorder.yaml +78 -7
- package/specs/target.yaml +11 -3
package/README.md
CHANGED
|
@@ -32,23 +32,41 @@ small summary (counts, coverage-free metrics, the first failures, and for JUnit
|
|
|
32
32
|
the per-test cases) and reports it to a NASEBANAL Assurance project. The same line works in a pipeline and on a
|
|
33
33
|
laptop; in CI it needs only a token:
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
| `contract` | `specmatic` | JUnit XML |
|
|
43
|
-
| `security` | `zap` | JUnit XML |
|
|
44
|
-
| `load` | `locust` | Locust CSV |
|
|
35
|
+
The **kind** is one of a fixed set of perspectives (`unit`, `e2e`, `contract`,
|
|
36
|
+
`security`, `load`); the **tool** is a free label (`vitest`, `pytest`, `playwright`,
|
|
37
|
+
`specmatic`, `zap`, `locust`, or `trivy`, `k6`, `cypress`...). What a result
|
|
38
|
+
means is carried by its kind and its numbers, so a scanner or runner can be swapped
|
|
39
|
+
without a change in Assurance. The CLI reads **JUnit XML** (any tool) and
|
|
40
|
+
**Locust CSV**; for a tool that writes something else, convert it to JUnit or give the
|
|
41
|
+
numbers with `--metric` (for a scanner: `critical`, `high`, `medium`, `low`, `info`).
|
|
45
42
|
|
|
46
43
|
```bash
|
|
47
44
|
export NB_TOKEN=nbpat_… # a PAT or service-account token (no browser needed)
|
|
48
45
|
|
|
49
46
|
# JUnit XML: vitest (--reporter=junit), pytest (--junitxml), Specmatic, Playwright (--reporter=junit)
|
|
50
47
|
nb assurance report upload --file report/junit.xml --kind unit --tool pytest
|
|
51
|
-
nb assurance report upload --file results/junit.xml --kind e2e --tool playwright --
|
|
48
|
+
nb assurance report upload --file results/junit.xml --kind e2e --tool playwright --component web
|
|
49
|
+
|
|
50
|
+
# Two scans of one tool on the same components (ZAP's baseline and full scan): a label tells them apart
|
|
51
|
+
nb assurance report upload --file report/baseline-junit.xml --kind security --tool zap --component frontend --label baseline
|
|
52
|
+
nb assurance report upload --file report/full-scan-junit.xml --kind security --tool zap --component frontend --label full-scan
|
|
53
|
+
|
|
54
|
+
# The tool's own version is informational and optional
|
|
55
|
+
nb assurance report upload --file report/junit.xml --kind unit --tool pytest --tool-version 9.1.0
|
|
56
|
+
|
|
57
|
+
# A result may be about several components: repeat the flag (new names are created in the project; none = `default`)
|
|
58
|
+
nb assurance report upload --file results/junit.xml --kind e2e --tool playwright --component frontend --component backend
|
|
59
|
+
|
|
60
|
+
# Specmatic: the JUnit XML, plus its HTML report for the API coverage (coverage_pct = operations exercised / operations in the spec)
|
|
61
|
+
nb assurance report upload --file specmatic/junit/TEST-junit-jupiter.xml --kind contract --tool specmatic \
|
|
62
|
+
--coverage-report specmatic/report/test/html/index.html
|
|
63
|
+
|
|
64
|
+
# One repository, several API versions: label the result (the version is part of the suite's identity, so v1 and v2 of the same name sit side by side)
|
|
65
|
+
nb assurance report upload --file specmatic/v2/junit.xml --kind contract --tool specmatic --api-version v2
|
|
66
|
+
# unit tests hold for every version: leave --api-version out
|
|
67
|
+
|
|
68
|
+
# Extra numbers for the suite (repeatable), e.g. ZAP's findings by severity - what the Assurance security gate reads
|
|
69
|
+
nb assurance report upload --file zap/report/baseline-junit.xml --kind security --tool zap --metric high=0 --metric medium=3 --metric low=2
|
|
52
70
|
|
|
53
71
|
# Locust (--csv <prefix>): the *_stats.csv, with its *_failures / *_exceptions / *_stats_history siblings
|
|
54
72
|
nb assurance report upload --dir locust/logs/20261001_090000
|
|
@@ -108,27 +126,67 @@ release bundles a fixed set of API contract versions, recorded in
|
|
|
108
126
|
### Releasing (maintainers)
|
|
109
127
|
|
|
110
128
|
Published to npmjs.org through npm **trusted publishing** (OIDC): no npm token is
|
|
111
|
-
stored anywhere
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
stored anywhere, and none is needed to run the release script - what gates a release is
|
|
130
|
+
**write access to this repository** (the script pushes a branch and a tag; the `Release`
|
|
131
|
+
workflow then publishes, and npm accepts that only from this repo's `release.yml`). It does
|
|
132
|
+
not work from a fork.
|
|
133
|
+
|
|
134
|
+
Two commands, split by the PR merge (which stays yours; the script never merges):
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
bin/release.sh prepare 0.3.0 # before the merge: checks, version bump, tests, opens the release PR
|
|
138
|
+
# ... review and merge the PR ...
|
|
139
|
+
git checkout main && git pull
|
|
140
|
+
bin/release.sh publish # after the merge: tags v0.3.0 (asks first), waits for the workflow, checks npm
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Add `--dry-run` to either to do every check and print what it would do, writing nothing.
|
|
144
|
+
|
|
145
|
+
What it checks, and why:
|
|
146
|
+
|
|
147
|
+
- **Pinned contracts equal `released.json`.** The specs host serves only the latest
|
|
148
|
+
version, so a stale `spec-versions.json` fails `sync-specs` with a 404 (it happened for
|
|
149
|
+
`account` 1.4.0 -> 1.5.0). `prepare` stops and lists the differences;
|
|
150
|
+
`prepare <version> --sync-pins` adopts the released versions in the release PR.
|
|
151
|
+
Check https://api-specs.nasebanal.com/specs/released.json
|
|
152
|
+
- **Tag equals `package.json`'s version**, or the workflow fails.
|
|
153
|
+
- **The version is newer than `main`'s and not on npm yet**, and the tag does not exist.
|
|
154
|
+
- If the tag is pushed but the version is not on npm (the workflow failed), `publish` says
|
|
155
|
+
so and prints the `gh run rerun <id> --failed` to carry on - **do not tag again**.
|
|
156
|
+
|
|
157
|
+
By hand (what the script does): bump `version` in `package.json` and `package-lock.json`
|
|
158
|
+
(PR to `main`, squash-merge), tag the merge commit (`git tag vX.Y.Z && git push origin vX.Y.Z`),
|
|
159
|
+
and the workflow type-checks, tests and runs `npm publish --provenance`.
|
|
160
|
+
|
|
161
|
+
#### One-time setup: the Trusted Publisher on npmjs.com
|
|
162
|
+
|
|
163
|
+
Trusted publishing must be switched on by hand, once per package, by a maintainer of
|
|
164
|
+
`@nasebanal/cli` (npm only allows it for a package that already exists, so the first version,
|
|
165
|
+
0.1.0, was published by hand). On https://www.npmjs.com/package/@nasebanal/cli/access ->
|
|
166
|
+
**Trusted Publisher** -> **GitHub Actions**:
|
|
167
|
+
|
|
168
|
+
| Field | Value |
|
|
169
|
+
|---|---|
|
|
170
|
+
| Organization or user | `nasebanal` |
|
|
171
|
+
| Repository | `nb-cli` |
|
|
172
|
+
| Workflow filename | `release.yml` (the file name only, exact case) |
|
|
173
|
+
| Environment name | empty (the workflow uses none) |
|
|
174
|
+
| Allowed actions | **tick "Allow `npm publish`"**; leave "Allow `npm dist-tag`" unticked |
|
|
175
|
+
|
|
176
|
+
Without that tick the publish fails with `npm error 404 ... could not be found or you do not
|
|
177
|
+
have permission` on the `PUT` - npm answers 404 for "not allowed", which reads like a missing
|
|
178
|
+
package. (This is how the first run of the workflow, for 0.2.0, failed: the setup was not done.)
|
|
122
179
|
|
|
123
180
|
Notes:
|
|
124
181
|
|
|
125
182
|
- A manual `npm publish` needs `--@nasebanal:registry=https://registry.npmjs.org/`
|
|
126
183
|
if your `~/.npmrc` maps the `@nasebanal` scope to GitHub Packages (that mapping
|
|
127
184
|
outranks `publishConfig.registry`; `publishConfig` also pins the scoped key as a
|
|
128
|
-
safeguard). npm also requires a passkey (WebAuthn) 2FA for interactive publishes.
|
|
185
|
+
safeguard). npm also requires a passkey (WebAuthn) 2FA for interactive publishes. The
|
|
186
|
+
same override is needed for `npm view @nasebanal/cli` against the public registry.
|
|
129
187
|
- A freshly published version can 404 on the package document for a few minutes
|
|
130
188
|
while npm's cache catches up; `npm view` with `--prefer-online` or a retry is
|
|
131
|
-
enough.
|
|
189
|
+
enough (the script retries for you).
|
|
132
190
|
|
|
133
191
|
### From source (contributors)
|
|
134
192
|
|
package/dist/reports/locust.js
CHANGED
|
@@ -7,6 +7,20 @@
|
|
|
7
7
|
* task exception, or a failure ratio above the allowed one, fails the run.
|
|
8
8
|
*/
|
|
9
9
|
import { toRecords } from "./csv.js";
|
|
10
|
+
/**
|
|
11
|
+
* The CSV's column names changed between Locust versions: 1.x wrote "# requests" / "# failures" / "Median response
|
|
12
|
+
* time" (and named the aggregate row "Total"), 2.x writes "Request Count" / "Failure Count" / "Median Response Time"
|
|
13
|
+
* ("Aggregated"). Each value is read under every name it has had, the first present one winning.
|
|
14
|
+
*/
|
|
15
|
+
const COLUMNS = {
|
|
16
|
+
requests: ["Request Count", "# requests"],
|
|
17
|
+
failures: ["Failure Count", "# failures"],
|
|
18
|
+
median: ["Median Response Time", "Median response time"],
|
|
19
|
+
average: ["Average Response Time", "Average response time"],
|
|
20
|
+
max: ["Max Response Time", "Max response time"],
|
|
21
|
+
};
|
|
22
|
+
const has = (row, names) => names.some((n) => n in row);
|
|
23
|
+
const read = (row, names) => num(row[names.find((n) => n in row) ?? ""]);
|
|
10
24
|
function num(value) {
|
|
11
25
|
if (value === undefined || value === "" || value === "N/A")
|
|
12
26
|
return null;
|
|
@@ -16,21 +30,26 @@ function num(value) {
|
|
|
16
30
|
const round = (n, digits = 2) => Math.round(n * 10 ** digits) / 10 ** digits;
|
|
17
31
|
export function summariseLocust(files, maxFailRatio) {
|
|
18
32
|
const stats = toRecords(files.stats);
|
|
19
|
-
|
|
33
|
+
// The row of all requests together: "Aggregated" (2.x) or "Total" (1.x).
|
|
34
|
+
const total = stats.find((r) => r.Name === "Aggregated" || r.Name === "Total");
|
|
20
35
|
if (!total)
|
|
21
|
-
throw new Error('No "Aggregated" row in the stats CSV - is this a Locust *_stats.csv file?');
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
throw new Error('No "Aggregated" (or "Total") row in the stats CSV - is this a Locust *_stats.csv file?');
|
|
37
|
+
// Without the counts the numbers below would silently be 0, a green result for a report nobody understood: refuse it.
|
|
38
|
+
if (!has(total, COLUMNS.requests) || !has(total, COLUMNS.failures)) {
|
|
39
|
+
throw new Error(`The stats CSV has no request / failure count column (looked for ${[...COLUMNS.requests, ...COLUMNS.failures].map((n) => `"${n}"`).join(", ")}; found: ${Object.keys(total).join(", ")}) - is it from a Locust version this nb does not know?`);
|
|
40
|
+
}
|
|
41
|
+
const requests = read(total, COLUMNS.requests) ?? 0;
|
|
42
|
+
const failed = read(total, COLUMNS.failures) ?? 0;
|
|
24
43
|
const metrics = { requests, failures: failed };
|
|
25
44
|
const put = (key, value) => {
|
|
26
45
|
if (value !== null)
|
|
27
46
|
metrics[key] = round(value);
|
|
28
47
|
};
|
|
29
|
-
put("avg_ms",
|
|
30
|
-
put("p50_ms", num(total["50%"]) ??
|
|
48
|
+
put("avg_ms", read(total, COLUMNS.average));
|
|
49
|
+
put("p50_ms", num(total["50%"]) ?? read(total, COLUMNS.median));
|
|
31
50
|
put("p95_ms", num(total["95%"]));
|
|
32
51
|
put("p99_ms", num(total["99%"]));
|
|
33
|
-
put("max_ms",
|
|
52
|
+
put("max_ms", read(total, COLUMNS.max));
|
|
34
53
|
put("rps", num(total["Requests/s"]));
|
|
35
54
|
const ratio = requests > 0 ? failed / requests : 0;
|
|
36
55
|
metrics.error_rate_pct = round(ratio * 100);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Specmatic's API coverage -> the `coverage_pct` metric of a `contract` suite.
|
|
3
|
+
*
|
|
4
|
+
* `specmatic test` writes JUnit XML (the pass/fail counts) and an HTML report. The coverage - how many of
|
|
5
|
+
* the spec's operations a test actually exercised - is only in that report, as the JSON the page renders
|
|
6
|
+
* from (`const report = {...}`, a CTRF-style document: `results.summary.extra.executionDetails[]` with a
|
|
7
|
+
* `coverageMetrics` per spec). This reads the number out of either that HTML file or a plain JSON copy of
|
|
8
|
+
* the same document, so the spec-derived coverage reaches Assurance without the spec itself being uploaded.
|
|
9
|
+
*
|
|
10
|
+
* The HTML embedding is Specmatic's own, not a documented interface: when it changes the parse fails with
|
|
11
|
+
* a message naming the file, and the upload (without coverage) is still one flag away.
|
|
12
|
+
*/
|
|
13
|
+
/** The end index (exclusive) of the JSON object that starts at `start`, honouring strings and escapes. */
|
|
14
|
+
function objectEnd(text, start) {
|
|
15
|
+
let depth = 0;
|
|
16
|
+
let inString = false;
|
|
17
|
+
for (let i = start; i < text.length; i++) {
|
|
18
|
+
const c = text[i];
|
|
19
|
+
if (inString) {
|
|
20
|
+
if (c === "\\")
|
|
21
|
+
i++;
|
|
22
|
+
else if (c === '"')
|
|
23
|
+
inString = false;
|
|
24
|
+
}
|
|
25
|
+
else if (c === '"')
|
|
26
|
+
inString = true;
|
|
27
|
+
else if (c === "{")
|
|
28
|
+
depth++;
|
|
29
|
+
else if (c === "}" && --depth === 0)
|
|
30
|
+
return i + 1;
|
|
31
|
+
}
|
|
32
|
+
throw new Error("unterminated JSON object");
|
|
33
|
+
}
|
|
34
|
+
function reportJson(text) {
|
|
35
|
+
const trimmed = text.trimStart();
|
|
36
|
+
if (trimmed.startsWith("{"))
|
|
37
|
+
return JSON.parse(trimmed);
|
|
38
|
+
const marker = "const report = ";
|
|
39
|
+
const at = text.indexOf(marker);
|
|
40
|
+
if (at < 0)
|
|
41
|
+
throw new Error("no embedded report found");
|
|
42
|
+
const start = at + marker.length;
|
|
43
|
+
return JSON.parse(text.slice(start, objectEnd(text, start)));
|
|
44
|
+
}
|
|
45
|
+
export function parseSpecmaticCoverage(text) {
|
|
46
|
+
let doc;
|
|
47
|
+
try {
|
|
48
|
+
doc = reportJson(text);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new Error(`Cannot read the Specmatic report (${err instanceof Error ? err.message : String(err)})`);
|
|
52
|
+
}
|
|
53
|
+
const details = doc?.results?.summary?.extra?.executionDetails;
|
|
54
|
+
if (!Array.isArray(details) || details.length === 0) {
|
|
55
|
+
throw new Error("The Specmatic report has no executionDetails (was it written by `specmatic test`?)");
|
|
56
|
+
}
|
|
57
|
+
let covered = 0;
|
|
58
|
+
let total = 0;
|
|
59
|
+
for (const d of details) {
|
|
60
|
+
const m = d.coverageMetrics;
|
|
61
|
+
if (!m)
|
|
62
|
+
continue;
|
|
63
|
+
covered += m.coveredOperations ?? 0;
|
|
64
|
+
total += m.totalOperationsWithFilters ?? m.totalOperations ?? 0;
|
|
65
|
+
}
|
|
66
|
+
if (total <= 0)
|
|
67
|
+
throw new Error("The Specmatic report lists no operations to cover");
|
|
68
|
+
return { coverage_pct: Math.round((covered / total) * 1000) / 10, covered, total };
|
|
69
|
+
}
|
package/dist/reports/upload.js
CHANGED
|
@@ -21,37 +21,61 @@ import { printJson } from "../output.js";
|
|
|
21
21
|
import { detectCi } from "./ci.js";
|
|
22
22
|
import { parseJunit } from "./junit.js";
|
|
23
23
|
import { summariseLocust } from "./locust.js";
|
|
24
|
+
import { parseSpecmaticCoverage } from "./specmatic.js";
|
|
24
25
|
export const SUITE_KINDS = ["unit", "e2e", "load", "contract", "security"];
|
|
25
26
|
/**
|
|
26
|
-
* The
|
|
27
|
-
*
|
|
28
|
-
* (
|
|
29
|
-
* Keep the two in step with the `tool` enum in nb-api-specs' assurance package.
|
|
27
|
+
* The tool is a label (nb-assurance-api `TOOL_PATTERN`): what a result means is carried by its kind and its
|
|
28
|
+
* numbers, so any scanner or runner can be reported without a change here, as long as its report is
|
|
29
|
+
* JUnit XML (or the numbers are given with --metric). Only the formats this CLI can read matter.
|
|
30
30
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
e2e: ["playwright"],
|
|
34
|
-
contract: ["specmatic"],
|
|
35
|
-
security: ["zap"],
|
|
36
|
-
load: ["locust"],
|
|
37
|
-
};
|
|
38
|
-
/** Tools whose report is not JUnit XML. Every other allowed tool writes JUnit. */
|
|
31
|
+
const TOOL_PATTERN = /^[a-z0-9][a-z0-9._-]{0,49}$/;
|
|
32
|
+
/** Tools whose report is not JUnit XML; every other tool is read as JUnit. */
|
|
39
33
|
const NON_JUNIT_TOOLS = { locust: "locust" };
|
|
40
34
|
/**
|
|
41
35
|
* Check a kind / tool / format combination before anything is parsed or sent.
|
|
42
|
-
* Returns the normalised (lower-case) tool
|
|
36
|
+
* Returns the normalised (lower-case) tool label; throws a message naming what is wrong.
|
|
43
37
|
*/
|
|
44
38
|
export function checkSuite(kind, tool, format) {
|
|
45
39
|
const name = tool.trim().toLowerCase();
|
|
46
|
-
if (!
|
|
47
|
-
throw new Error(`Tool '${tool}' is not
|
|
40
|
+
if (!TOOL_PATTERN.test(name)) {
|
|
41
|
+
throw new Error(`Tool '${tool}' is not a label: use lower-case letters, digits, '.', '_' and '-' (up to 50), e.g. vitest or trivy`);
|
|
48
42
|
}
|
|
49
43
|
const expected = NON_JUNIT_TOOLS[name] ?? "junit";
|
|
50
44
|
if (format !== expected) {
|
|
51
45
|
throw new Error(`Tool '${name}' reports in ${expected} format, but --format is ${format}`);
|
|
52
46
|
}
|
|
47
|
+
if (kind === "load" && name !== "locust" && format !== "junit")
|
|
48
|
+
throw new Error("Only locust reports are read as CSV");
|
|
53
49
|
return name;
|
|
54
50
|
}
|
|
51
|
+
/** The same label rule as the API (`utils/apiVersion.ts` in nb-assurance-api): checked here so a typo fails before anything is sent. */
|
|
52
|
+
const API_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
53
|
+
const METRIC_KEY = /^[a-z][a-z0-9_]{0,39}$/;
|
|
54
|
+
const MAX_METRICS = 20;
|
|
55
|
+
/** `--metric high=0 --metric medium=3`: extra numbers for the suite (the API keeps at most 20). */
|
|
56
|
+
export function parseMetrics(pairs) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const pair of pairs) {
|
|
59
|
+
const eq = pair.indexOf("=");
|
|
60
|
+
const key = eq < 0 ? pair : pair.slice(0, eq).trim();
|
|
61
|
+
const raw = eq < 0 ? "" : pair.slice(eq + 1).trim();
|
|
62
|
+
const value = Number(raw);
|
|
63
|
+
if (!METRIC_KEY.test(key) || raw === "" || !Number.isFinite(value)) {
|
|
64
|
+
throw new Error(`--metric '${pair}' must be <key>=<number> with a snake_case key (e.g. high=0)`);
|
|
65
|
+
}
|
|
66
|
+
out[key] = value;
|
|
67
|
+
}
|
|
68
|
+
if (Object.keys(out).length > MAX_METRICS)
|
|
69
|
+
throw new Error(`At most ${MAX_METRICS} --metric values per suite`);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
export function checkApiVersion(label) {
|
|
73
|
+
const v = label.trim();
|
|
74
|
+
if (!API_VERSION_PATTERN.test(v)) {
|
|
75
|
+
throw new Error(`--api-version '${label}' is not a version label: use letters, digits, '.', '_' and '-' (up to 32), e.g. v1 or 2026-10`);
|
|
76
|
+
}
|
|
77
|
+
return v;
|
|
78
|
+
}
|
|
55
79
|
const MAX_FAILURES = 20;
|
|
56
80
|
/** Per-test cases: the API accepts this many (MAX_CASES), each message up to 2000 characters. */
|
|
57
81
|
export const MAX_CASES = 5000;
|
|
@@ -103,7 +127,13 @@ function locustSibling(statsFile, suffix) {
|
|
|
103
127
|
}
|
|
104
128
|
export function parseReport(opts) {
|
|
105
129
|
const text = readFileSync(opts.file, "utf8");
|
|
106
|
-
const base = {
|
|
130
|
+
const base = {
|
|
131
|
+
kind: opts.kind,
|
|
132
|
+
tool: opts.tool,
|
|
133
|
+
...(opts.components && opts.components.length > 0 ? { components: opts.components } : {}),
|
|
134
|
+
...(opts.label ? { label: opts.label } : {}),
|
|
135
|
+
...(opts.toolVersion ? { tool_version: opts.toolVersion } : {}),
|
|
136
|
+
};
|
|
107
137
|
if (opts.format === "junit") {
|
|
108
138
|
const j = parseJunit(text);
|
|
109
139
|
const status = opts.status ?? (j.failed === 0 ? "passed" : "failed");
|
|
@@ -190,6 +220,49 @@ function statsFileIn(dir) {
|
|
|
190
220
|
fail(`No *_stats.csv file in ${dir}`);
|
|
191
221
|
return join(dir, files.sort()[files.length - 1]);
|
|
192
222
|
}
|
|
223
|
+
/** At most this many components per result (the API's limit) and this many characters in a name. */
|
|
224
|
+
const MAX_COMPONENTS = 20;
|
|
225
|
+
const MAX_COMPONENT_LENGTH = 100;
|
|
226
|
+
/** The `--label`: trimmed, not empty, within the API's limit. */
|
|
227
|
+
function checkLabel(raw) {
|
|
228
|
+
if (raw === undefined)
|
|
229
|
+
return undefined;
|
|
230
|
+
const label = raw.trim();
|
|
231
|
+
if (label === "")
|
|
232
|
+
fail("--label must not be empty");
|
|
233
|
+
if (label.length > MAX_COMPONENT_LENGTH)
|
|
234
|
+
fail(`--label must be ${MAX_COMPONENT_LENGTH} characters or fewer`);
|
|
235
|
+
return label;
|
|
236
|
+
}
|
|
237
|
+
/** The `--tool-version`: trimmed, not empty, within the API's limit. */
|
|
238
|
+
function checkToolVersion(raw) {
|
|
239
|
+
if (raw === undefined)
|
|
240
|
+
return undefined;
|
|
241
|
+
const version = raw.trim();
|
|
242
|
+
if (version === "")
|
|
243
|
+
fail("--tool-version must not be empty");
|
|
244
|
+
if (version.length > 50)
|
|
245
|
+
fail("--tool-version must be 50 characters or fewer");
|
|
246
|
+
return version;
|
|
247
|
+
}
|
|
248
|
+
/** The `--component` names: trimmed, no empty one, no two alike ignoring case, within the API's limits. */
|
|
249
|
+
function checkComponents(raw) {
|
|
250
|
+
if (raw === undefined)
|
|
251
|
+
return undefined;
|
|
252
|
+
const out = [];
|
|
253
|
+
for (const value of raw) {
|
|
254
|
+
const name = value.trim();
|
|
255
|
+
if (name === "")
|
|
256
|
+
fail("--component must not be empty");
|
|
257
|
+
if (name.length > MAX_COMPONENT_LENGTH)
|
|
258
|
+
fail(`--component must be ${MAX_COMPONENT_LENGTH} characters or fewer: ${name.slice(0, 20)}…`);
|
|
259
|
+
if (!out.some((o) => o.toLowerCase() === name.toLowerCase()))
|
|
260
|
+
out.push(name);
|
|
261
|
+
}
|
|
262
|
+
if (out.length > MAX_COMPONENTS)
|
|
263
|
+
fail(`At most ${MAX_COMPONENTS} --component values`);
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
193
266
|
function resolveFormat(opts, file) {
|
|
194
267
|
if (opts.format !== undefined) {
|
|
195
268
|
if (opts.format !== "junit" && opts.format !== "locust")
|
|
@@ -207,10 +280,15 @@ export function buildReportUploadCommand() {
|
|
|
207
280
|
.description("Report a test result file (JUnit XML or Locust CSV) to an Assurance project")
|
|
208
281
|
.option("--file <path>", "the report file (JUnit XML, or Locust *_stats.csv)")
|
|
209
282
|
.option("--dir <dir>", "a Locust output directory (uses its *_stats.csv)")
|
|
283
|
+
.option("--coverage-report <path>", "specmatic: its report (the HTML or a JSON copy); adds the API coverage as the coverage_pct metric")
|
|
210
284
|
.option("--format <format>", "junit | locust (default: from the file name)")
|
|
211
285
|
.option("--kind <kind>", `what the suite proves: ${SUITE_KINDS.join(" | ")} (locust: load)`)
|
|
212
|
-
.option("--tool <
|
|
213
|
-
.option("--
|
|
286
|
+
.option("--tool <label>", "the producing tool, a free label, e.g. vitest, pytest, playwright, specmatic, zap, trivy (locust: locust)")
|
|
287
|
+
.option("--component <name>", "a component the result is for (e.g. frontend, backend), repeatable when it covers several (e.g. an end-to-end test: --component frontend --component backend); new names are created in the project; leave it out for the component `default`", (v, all = []) => [...all, v])
|
|
288
|
+
.option("--label <name>", "tells apart results that are otherwise the same, such as two scans of one tool on the same components (e.g. --label baseline, --label full-scan); part of the result's identity, so the same label again replaces the result; optional")
|
|
289
|
+
.option("--tool-version <version>", "the version of the tool that produced the report (e.g. 9.1.0): informational, shown next to the tool, handy when a tool's new version changes its layout; optional")
|
|
290
|
+
.option("--metric <key=value>", "an extra number for the suite, repeatable (e.g. --metric high=0 --metric medium=3; for security: critical, high, medium, low, info)", (v, all = []) => [...all, v])
|
|
291
|
+
.option("--api-version <label>", "the API version the result is for (e.g. v1, v2), part of the result's identity next to its kind and components; leave it out for results that hold for every version, such as unit tests")
|
|
214
292
|
.option("--project <name>", "project name (CI: the repository); found, or created if missing")
|
|
215
293
|
.option("--repo <repo>", "repository recorded on a newly created project (CI: detected)")
|
|
216
294
|
.option("--run-key <key>", "identity of the pipeline run (CI: detected); all jobs of a run share it")
|
|
@@ -240,15 +318,37 @@ export function buildReportUploadCommand() {
|
|
|
240
318
|
const maxFailRatio = Number(ratioRaw);
|
|
241
319
|
if (!Number.isFinite(maxFailRatio) || maxFailRatio < 0 || maxFailRatio > 1)
|
|
242
320
|
fail("--max-fail-ratio must be a number between 0 and 1");
|
|
321
|
+
const apiVersion = opts.apiVersion === undefined ? undefined : checkApiVersion(opts.apiVersion);
|
|
243
322
|
const parsed = parseReport({
|
|
244
323
|
format,
|
|
245
324
|
kind,
|
|
246
325
|
tool: checkedTool,
|
|
247
|
-
|
|
326
|
+
components: checkComponents(opts.component),
|
|
327
|
+
label: checkLabel(opts.label),
|
|
328
|
+
toolVersion: checkToolVersion(opts.toolVersion),
|
|
248
329
|
status: opts.status,
|
|
249
330
|
maxFailRatio,
|
|
250
331
|
file,
|
|
251
332
|
});
|
|
333
|
+
if (opts.coverageReport !== undefined) {
|
|
334
|
+
if (kind !== "contract" || checkedTool !== "specmatic")
|
|
335
|
+
fail("--coverage-report is only for --kind contract --tool specmatic");
|
|
336
|
+
if (!existsSync(opts.coverageReport))
|
|
337
|
+
fail(`File not found: ${opts.coverageReport}`);
|
|
338
|
+
const cov = parseSpecmaticCoverage(readFileSync(opts.coverageReport, "utf8"));
|
|
339
|
+
parsed.suite.metrics = { ...parsed.suite.metrics, coverage_pct: cov.coverage_pct };
|
|
340
|
+
parsed.note += `, API coverage ${cov.coverage_pct}% (${cov.covered}/${cov.total} operations)`;
|
|
341
|
+
}
|
|
342
|
+
if (apiVersion !== undefined)
|
|
343
|
+
parsed.suite.api_version = apiVersion;
|
|
344
|
+
if (opts.metric !== undefined && opts.metric.length > 0) {
|
|
345
|
+
const extra = parseMetrics(opts.metric);
|
|
346
|
+
const merged = { ...parsed.suite.metrics, ...extra };
|
|
347
|
+
if (Object.keys(merged).length > MAX_METRICS)
|
|
348
|
+
fail(`At most ${MAX_METRICS} metrics per suite`);
|
|
349
|
+
parsed.suite.metrics = merged;
|
|
350
|
+
parsed.note += `, metrics ${Object.entries(extra).map(([k, v]) => `${k}=${v}`).join(" ")}`;
|
|
351
|
+
}
|
|
252
352
|
const ci = detectCi(process.env);
|
|
253
353
|
const runKey = opts.runKey ?? ci.runKey ?? fail("Cannot determine the run key; pass --run-key");
|
|
254
354
|
const project = opts.project ?? ci.project ?? fail("Cannot determine the project name; pass --project <name>");
|
package/package.json
CHANGED
package/specs/account.yaml
CHANGED
|
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
|
|
2
2
|
info:
|
|
3
3
|
title: NASEBANAL Account API
|
|
4
4
|
description: Centralized user and billing management API for NASEBANAL applications. Manages user profiles, per-service subscriptions, Stripe billing, and Personal Access Tokens (PAT).
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.6.0
|
|
6
6
|
contact:
|
|
7
7
|
name: NASEBANAL
|
|
8
8
|
url: https://www.nasebanal.com
|
|
@@ -149,7 +149,14 @@ paths:
|
|
|
149
149
|
application/json:
|
|
150
150
|
schema:
|
|
151
151
|
type: object
|
|
152
|
+
required:
|
|
153
|
+
- success
|
|
154
|
+
- data
|
|
152
155
|
properties:
|
|
156
|
+
success:
|
|
157
|
+
type: boolean
|
|
158
|
+
enum:
|
|
159
|
+
- true
|
|
153
160
|
data:
|
|
154
161
|
type: object
|
|
155
162
|
properties:
|
|
@@ -171,7 +178,14 @@ paths:
|
|
|
171
178
|
application/json:
|
|
172
179
|
schema:
|
|
173
180
|
type: object
|
|
181
|
+
required:
|
|
182
|
+
- success
|
|
183
|
+
- data
|
|
174
184
|
properties:
|
|
185
|
+
success:
|
|
186
|
+
type: boolean
|
|
187
|
+
enum:
|
|
188
|
+
- true
|
|
175
189
|
data:
|
|
176
190
|
type: array
|
|
177
191
|
items:
|
|
@@ -197,7 +211,14 @@ paths:
|
|
|
197
211
|
application/json:
|
|
198
212
|
schema:
|
|
199
213
|
type: object
|
|
214
|
+
required:
|
|
215
|
+
- success
|
|
216
|
+
- data
|
|
200
217
|
properties:
|
|
218
|
+
success:
|
|
219
|
+
type: boolean
|
|
220
|
+
enum:
|
|
221
|
+
- true
|
|
201
222
|
data:
|
|
202
223
|
$ref: "#/components/schemas/ServiceSubscription"
|
|
203
224
|
"401":
|
|
@@ -217,7 +238,14 @@ paths:
|
|
|
217
238
|
application/json:
|
|
218
239
|
schema:
|
|
219
240
|
type: object
|
|
241
|
+
required:
|
|
242
|
+
- success
|
|
243
|
+
- data
|
|
220
244
|
properties:
|
|
245
|
+
success:
|
|
246
|
+
type: boolean
|
|
247
|
+
enum:
|
|
248
|
+
- true
|
|
221
249
|
data:
|
|
222
250
|
type: object
|
|
223
251
|
properties:
|
|
@@ -247,7 +275,14 @@ paths:
|
|
|
247
275
|
application/json:
|
|
248
276
|
schema:
|
|
249
277
|
type: object
|
|
278
|
+
required:
|
|
279
|
+
- success
|
|
280
|
+
- data
|
|
250
281
|
properties:
|
|
282
|
+
success:
|
|
283
|
+
type: boolean
|
|
284
|
+
enum:
|
|
285
|
+
- true
|
|
251
286
|
data:
|
|
252
287
|
allOf:
|
|
253
288
|
- $ref: "#/components/schemas/ApiToken"
|
|
@@ -278,7 +313,14 @@ paths:
|
|
|
278
313
|
application/json:
|
|
279
314
|
schema:
|
|
280
315
|
type: object
|
|
316
|
+
required:
|
|
317
|
+
- success
|
|
318
|
+
- data
|
|
281
319
|
properties:
|
|
320
|
+
success:
|
|
321
|
+
type: boolean
|
|
322
|
+
enum:
|
|
323
|
+
- true
|
|
282
324
|
data:
|
|
283
325
|
type: object
|
|
284
326
|
properties:
|
|
@@ -319,7 +361,14 @@ paths:
|
|
|
319
361
|
application/json:
|
|
320
362
|
schema:
|
|
321
363
|
type: object
|
|
364
|
+
required:
|
|
365
|
+
- success
|
|
366
|
+
- data
|
|
322
367
|
properties:
|
|
368
|
+
success:
|
|
369
|
+
type: boolean
|
|
370
|
+
enum:
|
|
371
|
+
- true
|
|
323
372
|
data:
|
|
324
373
|
$ref: "#/components/schemas/NotificationInbox"
|
|
325
374
|
"401":
|
|
@@ -337,7 +386,14 @@ paths:
|
|
|
337
386
|
application/json:
|
|
338
387
|
schema:
|
|
339
388
|
type: object
|
|
389
|
+
required:
|
|
390
|
+
- success
|
|
391
|
+
- data
|
|
340
392
|
properties:
|
|
393
|
+
success:
|
|
394
|
+
type: boolean
|
|
395
|
+
enum:
|
|
396
|
+
- true
|
|
341
397
|
data:
|
|
342
398
|
type: object
|
|
343
399
|
properties:
|
|
@@ -366,7 +422,14 @@ paths:
|
|
|
366
422
|
application/json:
|
|
367
423
|
schema:
|
|
368
424
|
type: object
|
|
425
|
+
required:
|
|
426
|
+
- success
|
|
427
|
+
- data
|
|
369
428
|
properties:
|
|
429
|
+
success:
|
|
430
|
+
type: boolean
|
|
431
|
+
enum:
|
|
432
|
+
- true
|
|
370
433
|
data:
|
|
371
434
|
type: object
|
|
372
435
|
properties:
|
|
@@ -394,7 +457,14 @@ paths:
|
|
|
394
457
|
application/json:
|
|
395
458
|
schema:
|
|
396
459
|
type: object
|
|
460
|
+
required:
|
|
461
|
+
- success
|
|
462
|
+
- data
|
|
397
463
|
properties:
|
|
464
|
+
success:
|
|
465
|
+
type: boolean
|
|
466
|
+
enum:
|
|
467
|
+
- true
|
|
398
468
|
data:
|
|
399
469
|
type: object
|
|
400
470
|
properties:
|
|
@@ -427,7 +497,14 @@ paths:
|
|
|
427
497
|
application/json:
|
|
428
498
|
schema:
|
|
429
499
|
type: object
|
|
500
|
+
required:
|
|
501
|
+
- success
|
|
502
|
+
- data
|
|
430
503
|
properties:
|
|
504
|
+
success:
|
|
505
|
+
type: boolean
|
|
506
|
+
enum:
|
|
507
|
+
- true
|
|
431
508
|
data:
|
|
432
509
|
type: object
|
|
433
510
|
properties:
|
|
@@ -455,7 +532,14 @@ paths:
|
|
|
455
532
|
application/json:
|
|
456
533
|
schema:
|
|
457
534
|
type: object
|
|
535
|
+
required:
|
|
536
|
+
- success
|
|
537
|
+
- data
|
|
458
538
|
properties:
|
|
539
|
+
success:
|
|
540
|
+
type: boolean
|
|
541
|
+
enum:
|
|
542
|
+
- true
|
|
459
543
|
data:
|
|
460
544
|
type: object
|
|
461
545
|
properties:
|
|
@@ -495,7 +579,14 @@ paths:
|
|
|
495
579
|
application/json:
|
|
496
580
|
schema:
|
|
497
581
|
type: object
|
|
582
|
+
required:
|
|
583
|
+
- success
|
|
584
|
+
- data
|
|
498
585
|
properties:
|
|
586
|
+
success:
|
|
587
|
+
type: boolean
|
|
588
|
+
enum:
|
|
589
|
+
- true
|
|
499
590
|
data:
|
|
500
591
|
$ref: "#/components/schemas/ServiceAccount"
|
|
501
592
|
"401":
|
|
@@ -531,7 +622,14 @@ paths:
|
|
|
531
622
|
application/json:
|
|
532
623
|
schema:
|
|
533
624
|
type: object
|
|
625
|
+
required:
|
|
626
|
+
- success
|
|
627
|
+
- data
|
|
534
628
|
properties:
|
|
629
|
+
success:
|
|
630
|
+
type: boolean
|
|
631
|
+
enum:
|
|
632
|
+
- true
|
|
535
633
|
data:
|
|
536
634
|
$ref: "#/components/schemas/ServiceAccount"
|
|
537
635
|
"401":
|
|
@@ -563,7 +661,14 @@ paths:
|
|
|
563
661
|
application/json:
|
|
564
662
|
schema:
|
|
565
663
|
type: object
|
|
664
|
+
required:
|
|
665
|
+
- success
|
|
666
|
+
- data
|
|
566
667
|
properties:
|
|
668
|
+
success:
|
|
669
|
+
type: boolean
|
|
670
|
+
enum:
|
|
671
|
+
- true
|
|
567
672
|
data:
|
|
568
673
|
type: object
|
|
569
674
|
properties:
|
|
@@ -600,7 +705,14 @@ paths:
|
|
|
600
705
|
application/json:
|
|
601
706
|
schema:
|
|
602
707
|
type: object
|
|
708
|
+
required:
|
|
709
|
+
- success
|
|
710
|
+
- data
|
|
603
711
|
properties:
|
|
712
|
+
success:
|
|
713
|
+
type: boolean
|
|
714
|
+
enum:
|
|
715
|
+
- true
|
|
604
716
|
data:
|
|
605
717
|
type: object
|
|
606
718
|
properties:
|
|
@@ -645,7 +757,14 @@ paths:
|
|
|
645
757
|
application/json:
|
|
646
758
|
schema:
|
|
647
759
|
type: object
|
|
760
|
+
required:
|
|
761
|
+
- success
|
|
762
|
+
- data
|
|
648
763
|
properties:
|
|
764
|
+
success:
|
|
765
|
+
type: boolean
|
|
766
|
+
enum:
|
|
767
|
+
- true
|
|
649
768
|
data:
|
|
650
769
|
allOf:
|
|
651
770
|
- $ref: "#/components/schemas/ApiToken"
|
|
@@ -689,7 +808,14 @@ paths:
|
|
|
689
808
|
application/json:
|
|
690
809
|
schema:
|
|
691
810
|
type: object
|
|
811
|
+
required:
|
|
812
|
+
- success
|
|
813
|
+
- data
|
|
692
814
|
properties:
|
|
815
|
+
success:
|
|
816
|
+
type: boolean
|
|
817
|
+
enum:
|
|
818
|
+
- true
|
|
693
819
|
data:
|
|
694
820
|
type: object
|
|
695
821
|
properties:
|
|
@@ -761,7 +887,14 @@ components:
|
|
|
761
887
|
format: date-time
|
|
762
888
|
UserResponse:
|
|
763
889
|
type: object
|
|
890
|
+
required:
|
|
891
|
+
- success
|
|
892
|
+
- data
|
|
764
893
|
properties:
|
|
894
|
+
success:
|
|
895
|
+
type: boolean
|
|
896
|
+
enum:
|
|
897
|
+
- true
|
|
765
898
|
data:
|
|
766
899
|
$ref: "#/components/schemas/User"
|
|
767
900
|
UpdateProfileInput:
|
package/specs/recorder.yaml
CHANGED
|
@@ -30,7 +30,7 @@ info:
|
|
|
30
30
|
|
|
31
31
|
Publicly shared graphs can be embedded in external sites (blogs, portfolios, etc.) via iframe without authentication.
|
|
32
32
|
|
|
33
|
-
**Endpoint:** `GET /
|
|
33
|
+
**Endpoint:** `GET https://growth.nasebanal.com/{lang}/recorder/shared/{id}/embed` (served by NASEBANAL Growth, not by this API)
|
|
34
34
|
|
|
35
35
|
| Parameter | Type | Required | Description |
|
|
36
36
|
|-----------|------|----------|-------------|
|
|
@@ -43,11 +43,11 @@ info:
|
|
|
43
43
|
|
|
44
44
|
**iframe example:**
|
|
45
45
|
```html
|
|
46
|
-
<iframe src=\"https://
|
|
46
|
+
<iframe src=\"https://growth.nasebanal.com/ja/recorder/shared/{id}/embed\" width=\"100%\" height=\"500\" frameborder=\"0\"></iframe>
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
**Note:** The embed displays a snapshot of the data at the time the SNS share was created. To update the data, recreate the SNS share.
|
|
50
|
-
version: 1.
|
|
50
|
+
version: 1.7.0
|
|
51
51
|
license:
|
|
52
52
|
name: MIT
|
|
53
53
|
servers:
|
|
@@ -124,9 +124,19 @@ paths:
|
|
|
124
124
|
parameters:
|
|
125
125
|
- name: name
|
|
126
126
|
in: query
|
|
127
|
-
description: Filter by record name
|
|
127
|
+
description: "Filter by record name. A name is unique only per owner: a tag shared with you can have the same name as one of your own, and this then matches both. Use `tag_id` to pick exactly one tag."
|
|
128
128
|
schema:
|
|
129
129
|
type: string
|
|
130
|
+
- name: tag_id
|
|
131
|
+
in: query
|
|
132
|
+
description: Filter by tag id; repeat the parameter for several tags. An id names exactly one tag, so unlike `name` it cannot mix in a same-named tag of another owner. When `tag_id` is given, `name` is ignored. An id of a tag you cannot read (neither yours nor shared with you) matches nothing.
|
|
133
|
+
style: form
|
|
134
|
+
explode: true
|
|
135
|
+
schema:
|
|
136
|
+
type: array
|
|
137
|
+
items:
|
|
138
|
+
type: integer
|
|
139
|
+
minimum: 1
|
|
130
140
|
- name: start_date
|
|
131
141
|
in: query
|
|
132
142
|
description: Filter by start date (ISO 8601)
|
|
@@ -1450,17 +1460,22 @@ paths:
|
|
|
1450
1460
|
/records/stats:
|
|
1451
1461
|
get:
|
|
1452
1462
|
summary: Get record statistics
|
|
1453
|
-
description: Get aggregated statistics for records
|
|
1463
|
+
description: Get aggregated statistics for the records of one tag, picked by `name` or, unambiguously, by `tag_id`. Send one of the two.
|
|
1454
1464
|
operationId: getRecordStats
|
|
1455
1465
|
tags:
|
|
1456
1466
|
- Statistics
|
|
1457
1467
|
parameters:
|
|
1458
1468
|
- name: name
|
|
1459
1469
|
in: query
|
|
1460
|
-
|
|
1461
|
-
description: Record name to get statistics for
|
|
1470
|
+
description: Record name to get statistics for. A name is unique only per owner, so a tag shared with you can share it with one of your own; prefer `tag_id`.
|
|
1462
1471
|
schema:
|
|
1463
1472
|
type: string
|
|
1473
|
+
- name: tag_id
|
|
1474
|
+
in: query
|
|
1475
|
+
description: Id of the tag to get statistics for. When given, `name` is ignored. An id of a tag you cannot read matches nothing.
|
|
1476
|
+
schema:
|
|
1477
|
+
type: integer
|
|
1478
|
+
minimum: 1
|
|
1464
1479
|
responses:
|
|
1465
1480
|
"200":
|
|
1466
1481
|
description: Statistics for the specified record name
|
|
@@ -2184,6 +2199,45 @@ components:
|
|
|
2184
2199
|
- imported
|
|
2185
2200
|
- created
|
|
2186
2201
|
- updated
|
|
2202
|
+
SeriesKind:
|
|
2203
|
+
type: string
|
|
2204
|
+
enum:
|
|
2205
|
+
- raw
|
|
2206
|
+
- diff
|
|
2207
|
+
- diff_pct
|
|
2208
|
+
- sum
|
|
2209
|
+
- bmi
|
|
2210
|
+
- annual_income
|
|
2211
|
+
description: "Stable code of a derived series — not the display suffix (`/Diff` etc.). `diff` difference from the previous record, `diff_pct` percent change from the previous record, `sum` running total, `bmi` BMI (body-weight category), `annual_income` annual income (currency category). The raw tag is `raw`: it is the default on the referencing side and never appears in a `derived_series` list."
|
|
2212
|
+
DerivedSeries:
|
|
2213
|
+
type: object
|
|
2214
|
+
description: "A series derived from a tag. No row of its own: it is derived from the tag's settings (`derived_config`, `category`). Identified by the source tag's `id` plus `kind` — no name in it, so a rename cannot break a reference."
|
|
2215
|
+
properties:
|
|
2216
|
+
kind:
|
|
2217
|
+
$ref: "#/components/schemas/SeriesKind"
|
|
2218
|
+
label:
|
|
2219
|
+
type: string
|
|
2220
|
+
description: Display name (e.g. `Body Weight/BMI`). Not an identifier.
|
|
2221
|
+
unit:
|
|
2222
|
+
type: string
|
|
2223
|
+
nullable: true
|
|
2224
|
+
data_type:
|
|
2225
|
+
type: string
|
|
2226
|
+
enum:
|
|
2227
|
+
- decimal
|
|
2228
|
+
- integer
|
|
2229
|
+
- time
|
|
2230
|
+
chart_type:
|
|
2231
|
+
type: string
|
|
2232
|
+
enum:
|
|
2233
|
+
- line
|
|
2234
|
+
- smooth
|
|
2235
|
+
- step
|
|
2236
|
+
required:
|
|
2237
|
+
- kind
|
|
2238
|
+
- label
|
|
2239
|
+
- data_type
|
|
2240
|
+
- chart_type
|
|
2187
2241
|
Tag:
|
|
2188
2242
|
type: object
|
|
2189
2243
|
properties:
|
|
@@ -2213,6 +2267,11 @@ components:
|
|
|
2213
2267
|
is_hidden:
|
|
2214
2268
|
type: integer
|
|
2215
2269
|
description: 0 = visible, 1 = hidden
|
|
2270
|
+
derived_series:
|
|
2271
|
+
type: array
|
|
2272
|
+
description: Derived series this tag can produce. Excludes the raw series (`raw`).
|
|
2273
|
+
items:
|
|
2274
|
+
$ref: "#/components/schemas/DerivedSeries"
|
|
2216
2275
|
data_type_options:
|
|
2217
2276
|
type: array
|
|
2218
2277
|
description: "Which `data_type` this tag may be changed to, and why not for the rest. A change is safe exactly when no stored value changes meaning, which depends on the direction and on what is already stored — so this is server-computed rather than re-derived per client, and it carries a reason per option because the reason differs per option. A tag shared in from another user lists only its current type, not selectable: the recipient does not own the master data."
|
|
@@ -2487,6 +2546,10 @@ components:
|
|
|
2487
2546
|
virtual_tag_key:
|
|
2488
2547
|
type: string
|
|
2489
2548
|
nullable: true
|
|
2549
|
+
tag_kind:
|
|
2550
|
+
allOf:
|
|
2551
|
+
- $ref: "#/components/schemas/SeriesKind"
|
|
2552
|
+
description: Which series of the `tag_id` tag the goal targets. Absent or `raw` means the raw tag. Successor to `virtual_tag_key`, with no name in it.
|
|
2490
2553
|
is_hidden:
|
|
2491
2554
|
type: integer
|
|
2492
2555
|
description: 0 = visible, 1 = hidden
|
|
@@ -2542,6 +2605,10 @@ components:
|
|
|
2542
2605
|
virtual_tag_key:
|
|
2543
2606
|
type: string
|
|
2544
2607
|
description: Virtual tag key
|
|
2608
|
+
tag_kind:
|
|
2609
|
+
allOf:
|
|
2610
|
+
- $ref: "#/components/schemas/SeriesKind"
|
|
2611
|
+
description: Which series of the `tag_id` tag the goal targets. Absent or `raw` means the raw tag. Successor to `virtual_tag_key`, with no name in it.
|
|
2545
2612
|
required:
|
|
2546
2613
|
- tag_id
|
|
2547
2614
|
- goal_type
|
|
@@ -2569,6 +2636,10 @@ components:
|
|
|
2569
2636
|
type: string
|
|
2570
2637
|
virtual_tag_key:
|
|
2571
2638
|
type: string
|
|
2639
|
+
tag_kind:
|
|
2640
|
+
allOf:
|
|
2641
|
+
- $ref: "#/components/schemas/SeriesKind"
|
|
2642
|
+
description: Which series of the `tag_id` tag the goal targets. Absent or `raw` means the raw tag. Successor to `virtual_tag_key`, with no name in it.
|
|
2572
2643
|
ErrorResponse:
|
|
2573
2644
|
type: object
|
|
2574
2645
|
properties:
|
package/specs/target.yaml
CHANGED
|
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
|
|
2
2
|
info:
|
|
3
3
|
title: NASEBANAL - Target API
|
|
4
4
|
description: "Target API for NASEBANAL. Provides hierarchical target management: users define goals and organize them in a tree structure. Parent-child links are persisted via `targets.parent_target_id` (authoritative since migration 0017); a legacy cell-based storage layer is kept in sync best-effort for backward compatibility. Targets can be shared with other users (read/write, optionally including the subtree and linked Recorder data), and ownership of a personal target subtree can be transferred to a recipient via an email-approved migration flow. Supports user authentication via Auth0 JWT and Personal Access Tokens (PAT)."
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.5.0
|
|
6
6
|
contact:
|
|
7
7
|
name: NASEBANAL
|
|
8
8
|
url: https://www.nasebanal.com
|
|
@@ -1019,7 +1019,11 @@ components:
|
|
|
1019
1019
|
recorder_tag:
|
|
1020
1020
|
type: string
|
|
1021
1021
|
nullable: true
|
|
1022
|
-
description:
|
|
1022
|
+
description: Name of the linked Recorder tag, or a derived series key such as `Body Weight/BMI~` (a name, not a tag row, so it has no id). Kept alongside `recorder_tag_id` for derived keys and for clients that predate it.
|
|
1023
|
+
recorder_tag_id:
|
|
1024
|
+
type: integer
|
|
1025
|
+
nullable: true
|
|
1026
|
+
description: Id of the linked Recorder tag, for a real tag (never for a derived series key). Unlike the name it stays valid when the tag is renamed or its ownership is transferred, and it tells apart same-named tags of different owners. NULL for a target linked only by name; clients prefer the id when it is set and fall back to `recorder_tag`.
|
|
1023
1027
|
recorder_goal_id:
|
|
1024
1028
|
type: integer
|
|
1025
1029
|
nullable: true
|
|
@@ -1362,7 +1366,11 @@ components:
|
|
|
1362
1366
|
recorder_tag:
|
|
1363
1367
|
type: string
|
|
1364
1368
|
nullable: true
|
|
1365
|
-
description: Tag to link with Recorder app
|
|
1369
|
+
description: Tag to link with Recorder app, by name. Send the name together with `recorder_tag_id` for a real tag; for a derived series key (`Body Weight/BMI~`) this is the only field.
|
|
1370
|
+
recorder_tag_id:
|
|
1371
|
+
type: integer
|
|
1372
|
+
nullable: true
|
|
1373
|
+
description: Id of the Recorder tag to link, for a real tag. Send it together with `recorder_tag`; send `null` for both to unlink.
|
|
1366
1374
|
recorder_goal_id:
|
|
1367
1375
|
type: integer
|
|
1368
1376
|
nullable: true
|