@kirrosh/apitool 0.4.3
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/.github/workflows/ci.yml +27 -0
- package/.github/workflows/release.yml +97 -0
- package/.mcp.json +9 -0
- package/APITOOL.md +195 -0
- package/BACKLOG.md +62 -0
- package/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/bun.lock +291 -0
- package/docs/GLOSSARY.md +182 -0
- package/docs/INDEX.md +21 -0
- package/docs/agent.md +135 -0
- package/docs/archive/APITOOL-pre-M22.md +831 -0
- package/docs/archive/BACKLOG-AI-NATIVE.md +56 -0
- package/docs/archive/M1-M2-parser-runner.md +216 -0
- package/docs/archive/M4-M7-reporter-cli.md +179 -0
- package/docs/archive/M5-M7-storage-junit.md +300 -0
- package/docs/archive/M6-webui.md +339 -0
- package/docs/ci.md +274 -0
- package/docs/generation-issues.md +67 -0
- package/generated/.env.yaml +3 -0
- package/install.ps1 +80 -0
- package/install.sh +113 -0
- package/package.json +46 -0
- package/scripts/run-mocked-tests.ts +45 -0
- package/seed-demo.ts +53 -0
- package/self-tests/auth.yaml +18 -0
- package/self-tests/collections-crud.yaml +46 -0
- package/self-tests/environments-crud.yaml +48 -0
- package/self-tests/export.yaml +32 -0
- package/self-tests/runs.yaml +16 -0
- package/src/bun-types.d.ts +5 -0
- package/src/cli/commands/add-api.ts +51 -0
- package/src/cli/commands/ai-generate.ts +106 -0
- package/src/cli/commands/chat.ts +43 -0
- package/src/cli/commands/ci-init.ts +126 -0
- package/src/cli/commands/collections.ts +41 -0
- package/src/cli/commands/coverage.ts +65 -0
- package/src/cli/commands/doctor.ts +127 -0
- package/src/cli/commands/envs.ts +218 -0
- package/src/cli/commands/init.ts +84 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/run.ts +137 -0
- package/src/cli/commands/runs.ts +108 -0
- package/src/cli/commands/serve.ts +22 -0
- package/src/cli/commands/update.ts +142 -0
- package/src/cli/commands/validate.ts +18 -0
- package/src/cli/index.ts +500 -0
- package/src/cli/output.ts +24 -0
- package/src/cli/runtime.ts +7 -0
- package/src/core/agent/agent-loop.ts +116 -0
- package/src/core/agent/context-manager.ts +41 -0
- package/src/core/agent/system-prompt.ts +33 -0
- package/src/core/agent/tools/diagnose-failure.ts +51 -0
- package/src/core/agent/tools/explore-api.ts +40 -0
- package/src/core/agent/tools/index.ts +48 -0
- package/src/core/agent/tools/manage-environment.ts +40 -0
- package/src/core/agent/tools/query-results.ts +40 -0
- package/src/core/agent/tools/run-tests.ts +38 -0
- package/src/core/agent/tools/send-request.ts +44 -0
- package/src/core/agent/tools/validate-tests.ts +23 -0
- package/src/core/agent/types.ts +22 -0
- package/src/core/generator/ai/ai-generator.ts +61 -0
- package/src/core/generator/ai/llm-client.ts +159 -0
- package/src/core/generator/ai/output-parser.ts +307 -0
- package/src/core/generator/ai/prompt-builder.ts +153 -0
- package/src/core/generator/ai/types.ts +56 -0
- package/src/core/generator/coverage-scanner.ts +87 -0
- package/src/core/generator/data-factory.ts +115 -0
- package/src/core/generator/index.ts +10 -0
- package/src/core/generator/openapi-reader.ts +142 -0
- package/src/core/generator/schema-utils.ts +52 -0
- package/src/core/generator/serializer.ts +189 -0
- package/src/core/generator/types.ts +47 -0
- package/src/core/parser/filter.ts +14 -0
- package/src/core/parser/index.ts +21 -0
- package/src/core/parser/schema.ts +175 -0
- package/src/core/parser/types.ts +50 -0
- package/src/core/parser/variables.ts +146 -0
- package/src/core/parser/yaml-parser.ts +85 -0
- package/src/core/reporter/console.ts +175 -0
- package/src/core/reporter/index.ts +23 -0
- package/src/core/reporter/json.ts +9 -0
- package/src/core/reporter/junit.ts +78 -0
- package/src/core/reporter/types.ts +12 -0
- package/src/core/runner/assertions.ts +172 -0
- package/src/core/runner/execute-run.ts +75 -0
- package/src/core/runner/executor.ts +150 -0
- package/src/core/runner/http-client.ts +69 -0
- package/src/core/runner/index.ts +12 -0
- package/src/core/runner/types.ts +48 -0
- package/src/core/setup-api.ts +97 -0
- package/src/core/utils.ts +9 -0
- package/src/db/queries.ts +868 -0
- package/src/db/schema.ts +215 -0
- package/src/mcp/server.ts +47 -0
- package/src/mcp/tools/ci-init.ts +57 -0
- package/src/mcp/tools/coverage-analysis.ts +58 -0
- package/src/mcp/tools/explore-api.ts +84 -0
- package/src/mcp/tools/generate-missing-tests.ts +80 -0
- package/src/mcp/tools/generate-tests-guide.ts +353 -0
- package/src/mcp/tools/manage-environment.ts +123 -0
- package/src/mcp/tools/manage-server.ts +87 -0
- package/src/mcp/tools/query-db.ts +141 -0
- package/src/mcp/tools/run-tests.ts +66 -0
- package/src/mcp/tools/save-test-suite.ts +164 -0
- package/src/mcp/tools/send-request.ts +53 -0
- package/src/mcp/tools/setup-api.ts +49 -0
- package/src/mcp/tools/validate-tests.ts +42 -0
- package/src/tui/chat-ui.ts +150 -0
- package/src/web/routes/api.ts +234 -0
- package/src/web/routes/dashboard.ts +348 -0
- package/src/web/routes/runs.ts +64 -0
- package/src/web/schemas.ts +121 -0
- package/src/web/server.ts +134 -0
- package/src/web/static/htmx.min.js +1 -0
- package/src/web/static/style.css +265 -0
- package/src/web/views/layout.ts +46 -0
- package/src/web/views/results.ts +209 -0
- package/tests/agent/agent-loop.test.ts +61 -0
- package/tests/agent/context-manager.test.ts +59 -0
- package/tests/agent/system-prompt.test.ts +42 -0
- package/tests/agent/tools/diagnose-failure.test.ts +85 -0
- package/tests/agent/tools/explore-api.test.ts +59 -0
- package/tests/agent/tools/manage-environment.test.ts +78 -0
- package/tests/agent/tools/query-results.test.ts +77 -0
- package/tests/agent/tools/run-tests.test.ts +89 -0
- package/tests/agent/tools/send-request.test.ts +78 -0
- package/tests/agent/tools/validate-tests.test.ts +59 -0
- package/tests/ai/ai-generator.integration.test.ts +131 -0
- package/tests/ai/llm-client.test.ts +145 -0
- package/tests/ai/output-parser.test.ts +132 -0
- package/tests/ai/prompt-builder.test.ts +67 -0
- package/tests/ai/types.test.ts +55 -0
- package/tests/cli/args.test.ts +63 -0
- package/tests/cli/chat.test.ts +38 -0
- package/tests/cli/ci-init.test.ts +112 -0
- package/tests/cli/commands.test.ts +316 -0
- package/tests/cli/coverage.test.ts +58 -0
- package/tests/cli/doctor.test.ts +39 -0
- package/tests/cli/envs.test.ts +181 -0
- package/tests/cli/init.test.ts +80 -0
- package/tests/cli/runs.test.ts +94 -0
- package/tests/cli/safe-run.test.ts +103 -0
- package/tests/cli/update.test.ts +32 -0
- package/tests/core/generator/schema-utils.test.ts +108 -0
- package/tests/core/parser/nested-assertions.test.ts +80 -0
- package/tests/core/runner/root-body-assertions.test.ts +70 -0
- package/tests/db/chat-queries.test.ts +88 -0
- package/tests/db/chat-schema.test.ts +37 -0
- package/tests/db/environments.test.ts +131 -0
- package/tests/db/queries.test.ts +409 -0
- package/tests/db/schema.test.ts +141 -0
- package/tests/fixtures/.env.yaml +3 -0
- package/tests/fixtures/auth-token-test.yaml +8 -0
- package/tests/fixtures/bail/suite-a.yaml +6 -0
- package/tests/fixtures/bail/suite-b.yaml +6 -0
- package/tests/fixtures/crud.yaml +35 -0
- package/tests/fixtures/invalid-missing-name.yaml +5 -0
- package/tests/fixtures/invalid-no-method.yaml +6 -0
- package/tests/fixtures/petstore-auth.json +295 -0
- package/tests/fixtures/petstore-simple.json +151 -0
- package/tests/fixtures/post-only.yaml +12 -0
- package/tests/fixtures/simple.yaml +6 -0
- package/tests/fixtures/valid/.env.yaml +1 -0
- package/tests/fixtures/valid/a.yaml +5 -0
- package/tests/fixtures/valid/b.yml +5 -0
- package/tests/generator/coverage-scanner.test.ts +129 -0
- package/tests/generator/data-factory.test.ts +133 -0
- package/tests/generator/openapi-reader.test.ts +131 -0
- package/tests/integration/auth-flow.test.ts +217 -0
- package/tests/mcp/coverage-analysis.test.ts +64 -0
- package/tests/mcp/explore-api-schemas.test.ts +105 -0
- package/tests/mcp/explore-api.test.ts +49 -0
- package/tests/mcp/generate-missing-tests.test.ts +69 -0
- package/tests/mcp/manage-environment.test.ts +89 -0
- package/tests/mcp/save-test-suite.test.ts +116 -0
- package/tests/mcp/send-request.test.ts +79 -0
- package/tests/mcp/setup-api.test.ts +106 -0
- package/tests/mcp/tools.test.ts +248 -0
- package/tests/parser/schema.test.ts +134 -0
- package/tests/parser/variables.test.ts +227 -0
- package/tests/parser/yaml-parser.test.ts +69 -0
- package/tests/reporter/console.test.ts +256 -0
- package/tests/reporter/json.test.ts +98 -0
- package/tests/reporter/junit.test.ts +284 -0
- package/tests/runner/assertions.test.ts +262 -0
- package/tests/runner/executor.test.ts +310 -0
- package/tests/runner/http-client.test.ts +138 -0
- package/tests/web/routes.test.ts +160 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Glob } from "bun";
|
|
2
|
+
import type { EndpointInfo } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface CoveredEndpoint {
|
|
5
|
+
method: string;
|
|
6
|
+
path: string;
|
|
7
|
+
file: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scan YAML test files in outputDir and extract method+path from each test step.
|
|
14
|
+
* Uses simple regex to avoid importing the full parser.
|
|
15
|
+
*/
|
|
16
|
+
export async function scanCoveredEndpoints(outputDir: string): Promise<CoveredEndpoint[]> {
|
|
17
|
+
const covered: CoveredEndpoint[] = [];
|
|
18
|
+
|
|
19
|
+
const glob = new Glob("**/*.yaml");
|
|
20
|
+
for await (const file of glob.scan({ cwd: outputDir, absolute: true })) {
|
|
21
|
+
try {
|
|
22
|
+
const content = await Bun.file(file).text();
|
|
23
|
+
const lines = content.split("\n");
|
|
24
|
+
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
for (const method of HTTP_METHODS) {
|
|
28
|
+
// Match lines like "POST: /users" or "GET: /users/{{user_id}}"
|
|
29
|
+
if (trimmed.startsWith(`${method}:`) || trimmed.startsWith(`${method} :`)) {
|
|
30
|
+
const path = trimmed.slice(trimmed.indexOf(":") + 1).trim().replace(/^["']|["']$/g, "");
|
|
31
|
+
if (path) {
|
|
32
|
+
covered.push({ method, path: normalizePath(path), file });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Skip unreadable files
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return covered;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize path for comparison:
|
|
47
|
+
* - Replace {{variable}} with {*}
|
|
48
|
+
* - Replace {paramName} with {*}
|
|
49
|
+
* - Remove trailing slashes
|
|
50
|
+
*/
|
|
51
|
+
function normalizePath(path: string): string {
|
|
52
|
+
return path
|
|
53
|
+
.replace(/\{\{[^}]+\}\}/g, "{*}") // {{var}} → {*}
|
|
54
|
+
.replace(/\{[^}]+\}/g, "{*}") // {id} → {*}
|
|
55
|
+
.replace(/\/+$/, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Convert a spec path to a regex that matches both parameterized and concrete paths.
|
|
60
|
+
* e.g. /pet/{petId} matches /pet/100001 and /pet/{{pet_id}}
|
|
61
|
+
*/
|
|
62
|
+
function specPathToRegex(specPath: string): RegExp {
|
|
63
|
+
const pattern = specPath
|
|
64
|
+
.split("/")
|
|
65
|
+
.map((seg) =>
|
|
66
|
+
/^\{[^}]+\}$/.test(seg)
|
|
67
|
+
? "[^/]+"
|
|
68
|
+
: seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
|
69
|
+
)
|
|
70
|
+
.join("/");
|
|
71
|
+
return new RegExp(`^${pattern}$`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Filter endpoints that don't yet have test coverage.
|
|
76
|
+
*/
|
|
77
|
+
export function filterUncoveredEndpoints(
|
|
78
|
+
all: EndpointInfo[],
|
|
79
|
+
covered: CoveredEndpoint[],
|
|
80
|
+
): EndpointInfo[] {
|
|
81
|
+
return all.filter((ep) => {
|
|
82
|
+
const specRegex = specPathToRegex(ep.path);
|
|
83
|
+
return !covered.some(
|
|
84
|
+
(c) => c.method === ep.method && specRegex.test(normalizePath(c.path)),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recursively generates test data from an OpenAPI schema.
|
|
5
|
+
* Uses heuristic placeholders ({{$...}} generators) where possible.
|
|
6
|
+
*/
|
|
7
|
+
export function generateFromSchema(
|
|
8
|
+
schema: OpenAPIV3.SchemaObject,
|
|
9
|
+
propertyName?: string,
|
|
10
|
+
): unknown {
|
|
11
|
+
// allOf: merge all schemas
|
|
12
|
+
if (schema.allOf) {
|
|
13
|
+
const merged: OpenAPIV3.SchemaObject = { type: "object", properties: {} };
|
|
14
|
+
for (const sub of schema.allOf) {
|
|
15
|
+
const s = sub as OpenAPIV3.SchemaObject;
|
|
16
|
+
if (s.properties) {
|
|
17
|
+
merged.properties = { ...merged.properties, ...s.properties };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return generateFromSchema(merged, propertyName);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// oneOf / anyOf: use first variant
|
|
24
|
+
if (schema.oneOf) {
|
|
25
|
+
return generateFromSchema(schema.oneOf[0] as OpenAPIV3.SchemaObject, propertyName);
|
|
26
|
+
}
|
|
27
|
+
if (schema.anyOf) {
|
|
28
|
+
return generateFromSchema(schema.anyOf[0] as OpenAPIV3.SchemaObject, propertyName);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// enum: first value
|
|
32
|
+
if (schema.enum && schema.enum.length > 0) {
|
|
33
|
+
return schema.enum[0];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
switch (schema.type) {
|
|
37
|
+
case "string":
|
|
38
|
+
return guessStringPlaceholder(schema, propertyName);
|
|
39
|
+
|
|
40
|
+
case "integer":
|
|
41
|
+
return guessIntPlaceholder(propertyName);
|
|
42
|
+
|
|
43
|
+
case "number":
|
|
44
|
+
return "{{$randomInt}}";
|
|
45
|
+
|
|
46
|
+
case "boolean":
|
|
47
|
+
return true;
|
|
48
|
+
|
|
49
|
+
case "array": {
|
|
50
|
+
if (schema.items) {
|
|
51
|
+
const item = generateFromSchema(schema.items as OpenAPIV3.SchemaObject);
|
|
52
|
+
return [item];
|
|
53
|
+
}
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
case "object":
|
|
58
|
+
default: {
|
|
59
|
+
// Treat unknown type with properties as object
|
|
60
|
+
if (schema.properties) {
|
|
61
|
+
const obj: Record<string, unknown> = {};
|
|
62
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
63
|
+
obj[key] = generateFromSchema(propSchema as OpenAPIV3.SchemaObject, key);
|
|
64
|
+
}
|
|
65
|
+
return obj;
|
|
66
|
+
}
|
|
67
|
+
// Record type: additionalProperties defines value schema
|
|
68
|
+
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
69
|
+
const valSchema = schema.additionalProperties as OpenAPIV3.SchemaObject;
|
|
70
|
+
return { key1: generateFromSchema(valSchema, "key1"), key2: generateFromSchema(valSchema, "key2") };
|
|
71
|
+
}
|
|
72
|
+
if (schema.additionalProperties === true) {
|
|
73
|
+
return { key1: "value1", key2: "value2" };
|
|
74
|
+
}
|
|
75
|
+
// Bare object with no properties
|
|
76
|
+
if (schema.type === "object") {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
return "{{$randomString}}";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string): string {
|
|
85
|
+
// Format-based
|
|
86
|
+
if (schema.format === "email") return "{{$randomEmail}}";
|
|
87
|
+
if (schema.format === "uuid") return "{{$uuid}}";
|
|
88
|
+
if (schema.format === "date-time" || schema.format === "date") return "2025-01-01T00:00:00Z";
|
|
89
|
+
|
|
90
|
+
// Name-based heuristics
|
|
91
|
+
if (name) {
|
|
92
|
+
const lower = name.toLowerCase();
|
|
93
|
+
if (lower === "email" || lower.endsWith("_email") || lower.endsWith("Email")) {
|
|
94
|
+
return "{{$randomEmail}}";
|
|
95
|
+
}
|
|
96
|
+
if (lower === "id" || lower === "uuid" || lower.endsWith("_id") || lower.endsWith("id")) {
|
|
97
|
+
return "{{$uuid}}";
|
|
98
|
+
}
|
|
99
|
+
if (lower === "name" || lower.endsWith("_name") || lower.endsWith("Name")) {
|
|
100
|
+
return "{{$randomName}}";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return "{{$randomString}}";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function guessIntPlaceholder(name?: string): string {
|
|
108
|
+
if (name) {
|
|
109
|
+
const lower = name.toLowerCase();
|
|
110
|
+
if (lower === "id" || lower.endsWith("_id") || lower.endsWith("Id")) {
|
|
111
|
+
return "{{$randomInt}}";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return "{{$randomInt}}";
|
|
115
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { readOpenApiSpec, extractEndpoints, extractSecuritySchemes } from "./openapi-reader.ts";
|
|
2
|
+
export { serializeSuite, isRelativeUrl, sanitizeEnvName, resolveSpecPath } from "./serializer.ts";
|
|
3
|
+
export type { RawSuite, RawStep } from "./serializer.ts";
|
|
4
|
+
export { generateFromSchema } from "./data-factory.ts";
|
|
5
|
+
export { generateWithAI } from "./ai/ai-generator.ts";
|
|
6
|
+
export { resolveProviderConfig, PROVIDER_DEFAULTS } from "./ai/types.ts";
|
|
7
|
+
export type { AIProviderConfig, AIGenerateOptions, AIGenerateResult } from "./ai/types.ts";
|
|
8
|
+
export { scanCoveredEndpoints, filterUncoveredEndpoints } from "./coverage-scanner.ts";
|
|
9
|
+
export type { CoveredEndpoint } from "./coverage-scanner.ts";
|
|
10
|
+
export type { EndpointInfo, ResponseInfo, GenerateOptions, SecuritySchemeInfo, CrudGroup } from "./types.ts";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { dereference } from "@readme/openapi-parser";
|
|
2
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
3
|
+
import type { EndpointInfo, ResponseInfo, SecuritySchemeInfo } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
|
|
6
|
+
|
|
7
|
+
export async function readOpenApiSpec(specPath: string): Promise<OpenAPIV3.Document> {
|
|
8
|
+
// For HTTP URLs, fetch the spec first then dereference the parsed object
|
|
9
|
+
if (specPath.startsWith("http://") || specPath.startsWith("https://")) {
|
|
10
|
+
const resp = await fetch(specPath);
|
|
11
|
+
if (!resp.ok) throw new Error(`Failed to fetch spec: ${resp.status} ${resp.statusText}`);
|
|
12
|
+
const spec = await resp.json();
|
|
13
|
+
const api = await dereference(spec);
|
|
14
|
+
return api as OpenAPIV3.Document;
|
|
15
|
+
}
|
|
16
|
+
const api = await dereference(specPath);
|
|
17
|
+
return api as OpenAPIV3.Document;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function extractSecuritySchemes(doc: OpenAPIV3.Document): SecuritySchemeInfo[] {
|
|
21
|
+
const schemes: SecuritySchemeInfo[] = [];
|
|
22
|
+
const securitySchemes = doc.components?.securitySchemes;
|
|
23
|
+
if (!securitySchemes) return schemes;
|
|
24
|
+
|
|
25
|
+
for (const [name, schemeObj] of Object.entries(securitySchemes)) {
|
|
26
|
+
const scheme = schemeObj as OpenAPIV3.SecuritySchemeObject;
|
|
27
|
+
const info: SecuritySchemeInfo = {
|
|
28
|
+
name,
|
|
29
|
+
type: scheme.type as SecuritySchemeInfo["type"],
|
|
30
|
+
};
|
|
31
|
+
if (scheme.type === "http") {
|
|
32
|
+
info.scheme = scheme.scheme;
|
|
33
|
+
info.bearerFormat = scheme.bearerFormat;
|
|
34
|
+
}
|
|
35
|
+
if (scheme.type === "apiKey") {
|
|
36
|
+
info.in = scheme.in;
|
|
37
|
+
info.apiKeyName = scheme.name;
|
|
38
|
+
}
|
|
39
|
+
schemes.push(info);
|
|
40
|
+
}
|
|
41
|
+
return schemes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
|
|
45
|
+
const endpoints: EndpointInfo[] = [];
|
|
46
|
+
|
|
47
|
+
if (!doc.paths) return endpoints;
|
|
48
|
+
|
|
49
|
+
for (const [path, pathItem] of Object.entries(doc.paths)) {
|
|
50
|
+
if (!pathItem) continue;
|
|
51
|
+
|
|
52
|
+
for (const method of HTTP_METHODS) {
|
|
53
|
+
const operation = pathItem[method] as OpenAPIV3.OperationObject | undefined;
|
|
54
|
+
if (!operation) continue;
|
|
55
|
+
|
|
56
|
+
const parameters: OpenAPIV3.ParameterObject[] = [];
|
|
57
|
+
|
|
58
|
+
// Path-level parameters
|
|
59
|
+
if (pathItem.parameters) {
|
|
60
|
+
for (const p of pathItem.parameters) {
|
|
61
|
+
parameters.push(p as OpenAPIV3.ParameterObject);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Operation-level parameters (override path-level)
|
|
66
|
+
if (operation.parameters) {
|
|
67
|
+
for (const p of operation.parameters) {
|
|
68
|
+
const param = p as OpenAPIV3.ParameterObject;
|
|
69
|
+
const existingIdx = parameters.findIndex(
|
|
70
|
+
(existing) => existing.name === param.name && existing.in === param.in,
|
|
71
|
+
);
|
|
72
|
+
if (existingIdx >= 0) {
|
|
73
|
+
parameters[existingIdx] = param;
|
|
74
|
+
} else {
|
|
75
|
+
parameters.push(param);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Request body schema + content type
|
|
81
|
+
let requestBodySchema: OpenAPIV3.SchemaObject | undefined;
|
|
82
|
+
let requestBodyContentType: string | undefined;
|
|
83
|
+
if (operation.requestBody) {
|
|
84
|
+
const rb = operation.requestBody as OpenAPIV3.RequestBodyObject;
|
|
85
|
+
if (rb.content) {
|
|
86
|
+
// Prefer application/json, fall back to first available
|
|
87
|
+
const contentTypes = Object.keys(rb.content);
|
|
88
|
+
requestBodyContentType = contentTypes.includes("application/json")
|
|
89
|
+
? "application/json"
|
|
90
|
+
: contentTypes[0];
|
|
91
|
+
const chosen = rb.content[requestBodyContentType!];
|
|
92
|
+
if (chosen?.schema) {
|
|
93
|
+
requestBodySchema = chosen.schema as OpenAPIV3.SchemaObject;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Responses
|
|
99
|
+
const responses: ResponseInfo[] = [];
|
|
100
|
+
const responseContentTypesSet = new Set<string>();
|
|
101
|
+
if (operation.responses) {
|
|
102
|
+
for (const [statusCode, responseObj] of Object.entries(operation.responses)) {
|
|
103
|
+
const resp = responseObj as OpenAPIV3.ResponseObject;
|
|
104
|
+
const info: ResponseInfo = {
|
|
105
|
+
statusCode: parseInt(statusCode, 10),
|
|
106
|
+
description: resp.description || "",
|
|
107
|
+
};
|
|
108
|
+
if (resp.content) {
|
|
109
|
+
for (const ct of Object.keys(resp.content)) {
|
|
110
|
+
responseContentTypesSet.add(ct);
|
|
111
|
+
}
|
|
112
|
+
const jsonContent = resp.content["application/json"];
|
|
113
|
+
if (jsonContent?.schema) {
|
|
114
|
+
info.schema = jsonContent.schema as OpenAPIV3.SchemaObject;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
responses.push(info);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Security: operation-level overrides doc-level
|
|
122
|
+
const securityReqs = operation.security ?? doc.security ?? [];
|
|
123
|
+
const security = securityReqs.flatMap((req) => Object.keys(req));
|
|
124
|
+
|
|
125
|
+
endpoints.push({
|
|
126
|
+
path,
|
|
127
|
+
method: method.toUpperCase(),
|
|
128
|
+
operationId: operation.operationId,
|
|
129
|
+
summary: operation.summary,
|
|
130
|
+
tags: operation.tags ?? [],
|
|
131
|
+
parameters,
|
|
132
|
+
requestBodySchema,
|
|
133
|
+
requestBodyContentType,
|
|
134
|
+
responseContentTypes: [...responseContentTypesSet],
|
|
135
|
+
responses,
|
|
136
|
+
security,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return endpoints;
|
|
142
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true if the schema is effectively `any` — no type, no properties, no constraints.
|
|
5
|
+
*/
|
|
6
|
+
export function isAnySchema(schema: OpenAPIV3.SchemaObject | undefined): boolean {
|
|
7
|
+
if (!schema) return false;
|
|
8
|
+
return Object.keys(schema).length === 0 ||
|
|
9
|
+
(!schema.type && !schema.properties && !schema.enum && !schema.oneOf && !schema.allOf && !schema.anyOf);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compress an OpenAPI schema into a concise human-readable string.
|
|
14
|
+
* E.g. { name: string (req), age: integer, tags: [string] }
|
|
15
|
+
*/
|
|
16
|
+
export function compressSchema(schema: OpenAPIV3.SchemaObject, depth = 0): string {
|
|
17
|
+
if (depth > 2) return "{...}";
|
|
18
|
+
|
|
19
|
+
if (schema.type === "object" && schema.properties) {
|
|
20
|
+
const required = new Set(schema.required ?? []);
|
|
21
|
+
const fields = Object.entries(schema.properties).map(([key, propObj]) => {
|
|
22
|
+
const prop = propObj as OpenAPIV3.SchemaObject;
|
|
23
|
+
const type = prop.type ?? "any";
|
|
24
|
+
const flags: string[] = [];
|
|
25
|
+
if (required.has(key)) flags.push("req");
|
|
26
|
+
if (prop.format) flags.push(prop.format);
|
|
27
|
+
if (prop.enum) flags.push(`enum: ${prop.enum.join("|")}`);
|
|
28
|
+
const flagStr = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
|
29
|
+
return `${key}: ${type}${flagStr}`;
|
|
30
|
+
});
|
|
31
|
+
return `{ ${fields.join(", ")} }`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (schema.type === "array") {
|
|
35
|
+
const items = schema.items as OpenAPIV3.SchemaObject | undefined;
|
|
36
|
+
if (items) return `[${compressSchema(items, depth + 1)}]`;
|
|
37
|
+
return "[]";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return schema.type ?? "any";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Format an OpenAPI parameter into a concise string.
|
|
45
|
+
* E.g. "limit: integer" or "id: string (req)"
|
|
46
|
+
*/
|
|
47
|
+
export function formatParam(p: OpenAPIV3.ParameterObject): string {
|
|
48
|
+
const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
|
|
49
|
+
const type = schema?.type ?? "string";
|
|
50
|
+
const req = p.required ? " (req)" : "";
|
|
51
|
+
return `${p.name}: ${type}${req}`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
|
|
3
|
+
// ──────────────────────────────────────────────
|
|
4
|
+
// Utility functions (moved from skeleton.ts)
|
|
5
|
+
// ──────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export function isRelativeUrl(url: string): boolean {
|
|
8
|
+
return url.startsWith("/") && !url.includes("://");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveSpecPath(specPath: string): string {
|
|
12
|
+
if (specPath.startsWith("http://") || specPath.startsWith("https://")) {
|
|
13
|
+
return specPath;
|
|
14
|
+
}
|
|
15
|
+
return resolve(specPath);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sanitizeEnvName(name: string): string {
|
|
19
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 30);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ──────────────────────────────────────────────
|
|
23
|
+
// Types for raw suite serialization
|
|
24
|
+
// ──────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface RawStep {
|
|
27
|
+
name: string;
|
|
28
|
+
[methodKey: string]: unknown;
|
|
29
|
+
expect: {
|
|
30
|
+
status?: number;
|
|
31
|
+
body?: Record<string, Record<string, string>>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RawSuite {
|
|
36
|
+
name: string;
|
|
37
|
+
folder?: string;
|
|
38
|
+
fileStem?: string;
|
|
39
|
+
base_url?: string;
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
tests: RawStep[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ──────────────────────────────────────────────
|
|
45
|
+
// YAML serializer
|
|
46
|
+
// ──────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export function serializeSuite(suite: RawSuite): string {
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
lines.push(`name: ${yamlScalar(suite.name)}`);
|
|
51
|
+
if (suite.base_url) {
|
|
52
|
+
lines.push(`base_url: ${yamlScalar(suite.base_url)}`);
|
|
53
|
+
}
|
|
54
|
+
if (suite.headers && Object.keys(suite.headers).length > 0) {
|
|
55
|
+
lines.push("headers:");
|
|
56
|
+
for (const [hk, hv] of Object.entries(suite.headers)) {
|
|
57
|
+
lines.push(` ${hk}: ${yamlScalar(String(hv))}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
lines.push("tests:");
|
|
61
|
+
|
|
62
|
+
for (const test of suite.tests) {
|
|
63
|
+
lines.push(` - name: ${yamlScalar(test.name)}`);
|
|
64
|
+
|
|
65
|
+
// Write method-as-key (the shorthand)
|
|
66
|
+
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
|
|
67
|
+
if (method in test) {
|
|
68
|
+
lines.push(` ${method}: ${test[method]}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// headers
|
|
73
|
+
if (test.headers && Object.keys(test.headers as Record<string, string>).length > 0) {
|
|
74
|
+
lines.push(" headers:");
|
|
75
|
+
for (const [hk, hv] of Object.entries(test.headers as Record<string, string>)) {
|
|
76
|
+
lines.push(` ${hk}: ${yamlScalar(String(hv))}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// json body
|
|
81
|
+
if (test.json !== undefined) {
|
|
82
|
+
lines.push(" json:");
|
|
83
|
+
serializeValue(test.json, 3, lines);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// query
|
|
87
|
+
if (test.query) {
|
|
88
|
+
lines.push(" query:");
|
|
89
|
+
serializeValue(test.query, 3, lines);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// expect
|
|
93
|
+
lines.push(" expect:");
|
|
94
|
+
if (test.expect.status !== undefined) {
|
|
95
|
+
lines.push(` status: ${test.expect.status}`);
|
|
96
|
+
}
|
|
97
|
+
if (test.expect.body) {
|
|
98
|
+
lines.push(" body:");
|
|
99
|
+
for (const [key, rule] of Object.entries(test.expect.body)) {
|
|
100
|
+
lines.push(` ${key}:`);
|
|
101
|
+
for (const [rk, rv] of Object.entries(rule)) {
|
|
102
|
+
lines.push(` ${rk}: ${yamlScalar(String(rv))}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return lines.join("\n") + "\n";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function serializeValue(value: unknown, indent: number, lines: string[]): void {
|
|
112
|
+
const prefix = " ".repeat(indent);
|
|
113
|
+
|
|
114
|
+
if (value === null || value === undefined) {
|
|
115
|
+
lines.push(`${prefix}null`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
120
|
+
lines.push(`${prefix}${yamlScalar(String(value))}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
for (const item of value) {
|
|
126
|
+
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
127
|
+
const entries = Object.entries(item as Record<string, unknown>);
|
|
128
|
+
if (entries.length > 0) {
|
|
129
|
+
const [firstKey, firstVal] = entries[0]!;
|
|
130
|
+
lines.push(`${prefix}- ${firstKey}: ${formatInlineValue(firstVal)}`);
|
|
131
|
+
for (let i = 1; i < entries.length; i++) {
|
|
132
|
+
const [k, v] = entries[i]!;
|
|
133
|
+
lines.push(`${prefix} ${k}: ${formatInlineValue(v)}`);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
lines.push(`${prefix}- {}`);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
lines.push(`${prefix}- ${formatInlineValue(item)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof value === "object") {
|
|
146
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
147
|
+
if (typeof val === "object" && val !== null) {
|
|
148
|
+
lines.push(`${prefix}${key}:`);
|
|
149
|
+
serializeValue(val, indent + 1, lines);
|
|
150
|
+
} else {
|
|
151
|
+
lines.push(`${prefix}${key}: ${formatInlineValue(val)}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatInlineValue(val: unknown): string {
|
|
158
|
+
if (val === null || val === undefined) return "null";
|
|
159
|
+
if (typeof val === "string") return yamlScalar(val);
|
|
160
|
+
return String(val);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function yamlScalar(value: string): string {
|
|
164
|
+
if (
|
|
165
|
+
value === "" ||
|
|
166
|
+
value === "true" ||
|
|
167
|
+
value === "false" ||
|
|
168
|
+
value === "null" ||
|
|
169
|
+
value.includes(":") ||
|
|
170
|
+
value.includes("#") ||
|
|
171
|
+
value.includes("\n") ||
|
|
172
|
+
value.includes("'") ||
|
|
173
|
+
value.includes('"') ||
|
|
174
|
+
value.includes("{") ||
|
|
175
|
+
value.includes("}") ||
|
|
176
|
+
value.includes("[") ||
|
|
177
|
+
value.includes("]") ||
|
|
178
|
+
value.startsWith("&") ||
|
|
179
|
+
value.startsWith("*") ||
|
|
180
|
+
value.startsWith("!") ||
|
|
181
|
+
value.startsWith("%") ||
|
|
182
|
+
value.startsWith("@") ||
|
|
183
|
+
value.startsWith("`") ||
|
|
184
|
+
/^\d+$/.test(value)
|
|
185
|
+
) {
|
|
186
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
2
|
+
|
|
3
|
+
export interface ResponseInfo {
|
|
4
|
+
statusCode: number;
|
|
5
|
+
description: string;
|
|
6
|
+
schema?: OpenAPIV3.SchemaObject;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface EndpointInfo {
|
|
10
|
+
path: string;
|
|
11
|
+
method: string;
|
|
12
|
+
operationId?: string;
|
|
13
|
+
summary?: string;
|
|
14
|
+
tags: string[];
|
|
15
|
+
parameters: OpenAPIV3.ParameterObject[];
|
|
16
|
+
requestBodySchema?: OpenAPIV3.SchemaObject;
|
|
17
|
+
requestBodyContentType?: string;
|
|
18
|
+
responseContentTypes: string[];
|
|
19
|
+
responses: ResponseInfo[];
|
|
20
|
+
security: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SecuritySchemeInfo {
|
|
24
|
+
name: string;
|
|
25
|
+
type: "http" | "apiKey" | "oauth2" | "openIdConnect";
|
|
26
|
+
scheme?: string;
|
|
27
|
+
bearerFormat?: string;
|
|
28
|
+
in?: string;
|
|
29
|
+
apiKeyName?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CrudGroup {
|
|
33
|
+
resource: string;
|
|
34
|
+
basePath: string;
|
|
35
|
+
itemPath: string;
|
|
36
|
+
idParam: string;
|
|
37
|
+
create?: EndpointInfo;
|
|
38
|
+
list?: EndpointInfo;
|
|
39
|
+
read?: EndpointInfo;
|
|
40
|
+
update?: EndpointInfo;
|
|
41
|
+
delete?: EndpointInfo;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GenerateOptions {
|
|
45
|
+
specPath: string;
|
|
46
|
+
outputDir: string;
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TestSuite } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Filter suites by tags (OR logic, case-insensitive).
|
|
5
|
+
* Suites without tags are excluded when filtering is active.
|
|
6
|
+
*/
|
|
7
|
+
export function filterSuitesByTags(suites: TestSuite[], tags: string[]): TestSuite[] {
|
|
8
|
+
if (tags.length === 0) return suites;
|
|
9
|
+
const normalizedTags = tags.map(t => t.toLowerCase());
|
|
10
|
+
return suites.filter(suite => {
|
|
11
|
+
if (!suite.tags || suite.tags.length === 0) return false;
|
|
12
|
+
return suite.tags.some(t => normalizedTags.includes(t.toLowerCase()));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
HttpMethod,
|
|
3
|
+
AssertionRule,
|
|
4
|
+
TestStepExpect,
|
|
5
|
+
TestStep,
|
|
6
|
+
SuiteConfig,
|
|
7
|
+
TestSuite,
|
|
8
|
+
Environment,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export { validateSuite, DEFAULT_CONFIG } from "./schema.ts";
|
|
12
|
+
export {
|
|
13
|
+
GENERATORS,
|
|
14
|
+
substituteString,
|
|
15
|
+
substituteDeep,
|
|
16
|
+
substituteStep,
|
|
17
|
+
extractVariableReferences,
|
|
18
|
+
loadEnvironment,
|
|
19
|
+
} from "./variables.ts";
|
|
20
|
+
export { parse, parseFile, parseDirectory } from "./yaml-parser.ts";
|
|
21
|
+
export { filterSuitesByTags } from "./filter.ts";
|