@capagents/forgeqe 0.0.0-stage → 1.0.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/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
package/src/api/spec.ts
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { XMLParser } from "fast-xml-parser"
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import YAML from "yaml"
|
|
5
|
+
|
|
6
|
+
import { exampleFor } from "./schema.ts"
|
|
7
|
+
import type { Contract, ContractSet, JsonSchema, KeyField, SourceKind } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Contracts from an API description, given as a link or a file: OpenAPI 3 or Swagger 2 (JSON or YAML, or the Swagger UI
|
|
11
|
+
* page that shows one), WSDL for SOAP, a GraphQL endpoint (read by introspection), an introspection result or SDL file,
|
|
12
|
+
* and .proto files or a gRPC server with reflection (through grpcurl). One contract per operation, with an example call
|
|
13
|
+
* from the description's examples, and the status, schema, and fields its answer must have.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type Fetcher = (url: string, init?: RequestInit) => Promise<Response>
|
|
17
|
+
export type LoadOptions = { headers?: Record<string, string>; fetch?: Fetcher; include?: string[]; grpcurl?: (args: string[], input?: string) => Promise<{ ok: boolean; out: string }> }
|
|
18
|
+
|
|
19
|
+
type Loaded = { kind: SourceKind; name: string; baseUrl: string; contracts: Contract[]; skipped: string[] }
|
|
20
|
+
|
|
21
|
+
const READ_NAME = /^(get|list|find|search|query|read|fetch|lookup|check|validate|describe|count|is|has)/i
|
|
22
|
+
const KEY_NAME = /(^|_|\b)(id|uuid|key|code|status|state|type|kind|name|title|total|count|amount|price|currency|email|success|result)$/i
|
|
23
|
+
const METHODS = ["get", "post", "put", "patch", "delete", "head", "options"]
|
|
24
|
+
|
|
25
|
+
export const isUrl = (s: string) => /^https?:\/\//i.test(s)
|
|
26
|
+
|
|
27
|
+
async function getText(ref: string, opts: LoadOptions): Promise<{ text: string; url?: string; type: string }> {
|
|
28
|
+
if (!isUrl(ref)) {
|
|
29
|
+
const file = path.resolve(ref)
|
|
30
|
+
if (!existsSync(file)) throw new Error(`No such file or link: ${ref}`)
|
|
31
|
+
return { text: readFileSync(file, "utf8"), type: "" }
|
|
32
|
+
}
|
|
33
|
+
const res = await (opts.fetch || fetch)(ref, { headers: { Accept: "application/json, application/yaml, text/yaml, application/xml, text/xml, */*", ...(opts.headers || {}) } })
|
|
34
|
+
if (!res.ok) throw new Error(`${ref} answered HTTP ${res.status}`)
|
|
35
|
+
return { text: await res.text(), url: res.url || ref, type: res.headers.get("content-type") || "" }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Load an API description from a link or file and write a contract for each of its operations. */
|
|
39
|
+
export async function loadSpec(ref: string, opts: LoadOptions = {}): Promise<Loaded> {
|
|
40
|
+
const src = ref.trim()
|
|
41
|
+
if (/^grpcs?:\/\//i.test(src)) return grpcReflection(src, opts)
|
|
42
|
+
if (/\.proto$/i.test(src)) return fromProto([src], opts)
|
|
43
|
+
if (isUrl(src) && /\/graphql\/?(\?.*)?$/i.test(src)) return graphqlEndpoint(src, opts)
|
|
44
|
+
const { text, url, type } = await getText(src, opts)
|
|
45
|
+
const base = url || src
|
|
46
|
+
const t = text.trim()
|
|
47
|
+
if (/html/i.test(type) || /^<!doctype html|^<html/i.test(t)) return fromSwaggerUi(base, t, opts)
|
|
48
|
+
if (t.startsWith("<")) {
|
|
49
|
+
if (/definitions|description/.test(t.slice(0, 2000)) && /wsdl|schemas\.xmlsoap\.org/i.test(t.slice(0, 4000))) return fromWsdl(t, base, opts)
|
|
50
|
+
throw new Error(`${src} is XML but not a WSDL document.`)
|
|
51
|
+
}
|
|
52
|
+
if (/^\s*(syntax\s*=|package\s+[\w.]+;|service\s+\w+\s*{)/m.test(t) && /\brpc\s+\w+/.test(t)) return fromProto([src], opts, t)
|
|
53
|
+
let doc: unknown
|
|
54
|
+
try {
|
|
55
|
+
doc = t.startsWith("{") || t.startsWith("[") ? JSON.parse(t) : YAML.parse(t)
|
|
56
|
+
} catch {
|
|
57
|
+
doc = undefined
|
|
58
|
+
}
|
|
59
|
+
const d = doc as Record<string, any> | undefined
|
|
60
|
+
if (d && (d.openapi || d.swagger)) return fromOpenApi(d, base, opts)
|
|
61
|
+
if (d && (d.__schema || d.data?.__schema)) return fromIntrospection(d.data?.__schema || d.__schema, "", base, opts)
|
|
62
|
+
if (/\btype\s+(Query|Mutation)\b|\bschema\s*{/.test(t)) return fromSdl(t, base, opts)
|
|
63
|
+
throw new Error(`Could not tell what kind of API description ${src} is. ForgeQE reads OpenAPI or Swagger (JSON or YAML), WSDL, GraphQL (an endpoint, an introspection result, or SDL), and .proto files.`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A contract set from a description. */
|
|
67
|
+
export async function setFromSpec(ref: string, id: string, opts: LoadOptions & { name?: string; baseUrl?: string } = {}): Promise<ContractSet> {
|
|
68
|
+
const loaded = await loadSpec(ref, opts)
|
|
69
|
+
const include = (opts.include || []).map((s) => s.toLowerCase()).filter(Boolean)
|
|
70
|
+
const contracts = include.length ? loaded.contracts.filter((c) => include.some((i) => `${c.id} ${c.name}`.toLowerCase().includes(i))) : loaded.contracts
|
|
71
|
+
return {
|
|
72
|
+
version: 1,
|
|
73
|
+
id,
|
|
74
|
+
name: opts.name || loaded.name,
|
|
75
|
+
baseUrl: opts.baseUrl || loaded.baseUrl,
|
|
76
|
+
createdAt: new Date().toISOString(),
|
|
77
|
+
source: { kind: loaded.kind, ref },
|
|
78
|
+
contracts,
|
|
79
|
+
...(loaded.skipped.length ? { skipped: loaded.skipped } : {}),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// -- OpenAPI and Swagger ------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/** Swagger UI's page: find the description it shows (its config, the initializer script, or the usual paths). */
|
|
86
|
+
async function fromSwaggerUi(pageUrl: string, html: string, opts: LoadOptions): Promise<Loaded> {
|
|
87
|
+
const found = (text: string) => [...text.matchAll(/\burls?\s*[:=]\s*\[?\s*{?\s*(?:url\s*:\s*)?["'`]([^"'`]+\.(?:json|ya?ml)[^"'`]*|[^"'`]*api-docs[^"'`]*)["'`]/g)].map((m) => m[1])
|
|
88
|
+
const candidates = found(html)
|
|
89
|
+
for (const s of html.matchAll(/<script[^>]+src=["']([^"']*(?:initializer|swagger-config)[^"']*)["']/g)) {
|
|
90
|
+
try {
|
|
91
|
+
candidates.push(...found((await getText(new URL(s[1], pageUrl).href, opts)).text))
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
candidates.push("swagger.json", "openapi.json", "v1/swagger.json", "../swagger/v1/swagger.json", "/v3/api-docs", "/v2/api-docs", "/openapi.json", "/swagger.json")
|
|
95
|
+
for (const c of candidates) {
|
|
96
|
+
const url = new URL(c, pageUrl.endsWith("/") || /\.html?$/.test(pageUrl) ? pageUrl : `${pageUrl}/`).href
|
|
97
|
+
try {
|
|
98
|
+
const { text } = await getText(url, opts)
|
|
99
|
+
const d = text.trim().startsWith("{") ? JSON.parse(text) : YAML.parse(text)
|
|
100
|
+
if (d && (d.openapi || d.swagger)) return fromOpenApi(d, url, opts)
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`${pageUrl} is a web page, and no OpenAPI or Swagger document was found behind it. Give the link to the JSON or YAML document itself.`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function deref(node: any, root: any, seen: string[] = []): any {
|
|
107
|
+
if (!node || typeof node !== "object") return node
|
|
108
|
+
if (Array.isArray(node)) return node.map((n) => deref(n, root, seen))
|
|
109
|
+
if (typeof node.$ref === "string") {
|
|
110
|
+
const ref = node.$ref as string
|
|
111
|
+
if (!ref.startsWith("#/") || seen.includes(ref) || seen.length > 12) return {}
|
|
112
|
+
const target = ref
|
|
113
|
+
.slice(2)
|
|
114
|
+
.split("/")
|
|
115
|
+
.map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"))
|
|
116
|
+
.reduce((o: any, k) => (o ? o[k] : undefined), root)
|
|
117
|
+
return deref(target, root, [...seen, ref])
|
|
118
|
+
}
|
|
119
|
+
const out: any = {}
|
|
120
|
+
for (const [k, v] of Object.entries(node)) out[k] = k === "example" || k === "examples" ? v : deref(v, root, seen)
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** OpenAPI's schema object as ForgeQE's schema: nullable, x-nullable, and type lists. */
|
|
125
|
+
function toSchema(s: any): JsonSchema | undefined {
|
|
126
|
+
if (!s || typeof s !== "object") return undefined
|
|
127
|
+
const out: JsonSchema = {}
|
|
128
|
+
if (s.type) out.type = s.type
|
|
129
|
+
if (s.nullable || s["x-nullable"]) out.nullable = true
|
|
130
|
+
if (Array.isArray(s.type) && s.type.includes("null")) out.nullable = true
|
|
131
|
+
if (s.enum) out.enum = s.enum
|
|
132
|
+
if (s.format) out.format = s.format
|
|
133
|
+
if (s.example !== undefined) out.example = s.example
|
|
134
|
+
if (s.required && Array.isArray(s.required)) out.required = s.required
|
|
135
|
+
if (s.properties) out.properties = Object.fromEntries(Object.entries(s.properties).map(([k, v]) => [k, toSchema(v) || {}]))
|
|
136
|
+
if (s.items) out.items = toSchema(s.items)
|
|
137
|
+
for (const k of ["anyOf", "oneOf", "allOf"] as const) if (Array.isArray(s[k])) out[k] = s[k].map((x: any) => toSchema(x) || {})
|
|
138
|
+
if (!out.type && out.properties) out.type = "object"
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Fields to check from a response schema: required scalars named like ids, statuses, names, totals; their type only. */
|
|
143
|
+
export function schemaFields(schema: JsonSchema | undefined, max = 8): KeyField[] {
|
|
144
|
+
if (!schema) return []
|
|
145
|
+
const out: KeyField[] = []
|
|
146
|
+
const walk = (s: JsonSchema, at: string, depth: number) => {
|
|
147
|
+
if (depth > 3 || out.length >= max) return
|
|
148
|
+
if (s.allOf) for (const x of s.allOf) walk(x, at, depth)
|
|
149
|
+
const t = Array.isArray(s.type) ? s.type[0] : s.type
|
|
150
|
+
if ((t === "array" || s.items) && s.items) return walk(s.items, `${at}[0]`, depth + 1)
|
|
151
|
+
const req = new Set(s.required || [])
|
|
152
|
+
for (const [k, p] of Object.entries(s.properties || {})) {
|
|
153
|
+
if (out.length >= max) break
|
|
154
|
+
const pt = Array.isArray(p.type) ? p.type.find((x) => x !== "null") : p.type
|
|
155
|
+
if (req.has(k) && pt && !["object", "array"].includes(pt) && KEY_NAME.test(k)) out.push({ path: at ? `${at}.${k}` : k, type: pt === "integer" ? "number" : pt })
|
|
156
|
+
}
|
|
157
|
+
for (const [k, p] of Object.entries(s.properties || {})) if (req.has(k) && (p.properties || p.items)) walk(p, at ? `${at}.${k}` : k, depth + 1)
|
|
158
|
+
}
|
|
159
|
+
walk(schema, "", 0)
|
|
160
|
+
return out
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function firstExample(media: any): unknown {
|
|
164
|
+
if (!media) return undefined
|
|
165
|
+
if (media.example !== undefined) return media.example
|
|
166
|
+
const ex = media.examples && typeof media.examples === "object" ? Object.values(media.examples)[0] : undefined
|
|
167
|
+
if (ex && typeof ex === "object" && "value" in (ex as object)) return (ex as { value: unknown }).value
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function jsonMedia(content: any): [string, any] | undefined {
|
|
172
|
+
if (!content || typeof content !== "object") return undefined
|
|
173
|
+
const entries = Object.entries(content)
|
|
174
|
+
return (entries.find(([t]) => /json/i.test(t)) || entries.find(([t]) => /xml|text/i.test(t)) || entries[0]) as [string, any] | undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function fromOpenApi(raw: any, docUrl: string, opts: LoadOptions): Loaded {
|
|
178
|
+
const doc = raw
|
|
179
|
+
const v2 = Boolean(doc.swagger)
|
|
180
|
+
const skipped: string[] = []
|
|
181
|
+
let baseUrl = ""
|
|
182
|
+
if (v2) {
|
|
183
|
+
const scheme = (doc.schemes || []).includes("https") ? "https" : doc.schemes?.[0] || (isUrl(docUrl) ? new URL(docUrl).protocol.replace(":", "") : "https")
|
|
184
|
+
const host = doc.host || (isUrl(docUrl) ? new URL(docUrl).host : "")
|
|
185
|
+
baseUrl = host ? `${scheme}://${host}${(doc.basePath || "").replace(/\/$/, "")}` : ""
|
|
186
|
+
} else {
|
|
187
|
+
const server = doc.servers?.[0]
|
|
188
|
+
if (server?.url) {
|
|
189
|
+
let u = String(server.url)
|
|
190
|
+
for (const [k, v] of Object.entries(server.variables || {})) u = u.replace(`{${k}}`, String((v as { default?: string }).default ?? ""))
|
|
191
|
+
baseUrl = isUrl(u) ? u : isUrl(docUrl) ? new URL(u, docUrl).href : u
|
|
192
|
+
} else if (isUrl(docUrl)) baseUrl = new URL(docUrl).origin
|
|
193
|
+
}
|
|
194
|
+
baseUrl = baseUrl.replace(/\/+$/, "")
|
|
195
|
+
const schemes = (v2 ? doc.securityDefinitions : doc.components?.securitySchemes) || {}
|
|
196
|
+
const authOf = (security: any[] | undefined): Contract["auth"] | undefined => {
|
|
197
|
+
const first = (security || doc.security || [])[0]
|
|
198
|
+
const name = first ? Object.keys(first)[0] : undefined
|
|
199
|
+
const s = name ? schemes[name] : undefined
|
|
200
|
+
if (!s) return undefined
|
|
201
|
+
if (s.type === "apiKey") return { type: s.in === "header" ? "header" : s.in === "cookie" ? "cookie" : "query", name: s.name }
|
|
202
|
+
if (s.type === "http") return { type: String(s.scheme || "bearer").toLowerCase() }
|
|
203
|
+
if (s.type === "basic") return { type: "basic" }
|
|
204
|
+
return { type: s.type === "oauth2" || s.type === "openIdConnect" ? "bearer" : String(s.type) }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const contracts: Contract[] = []
|
|
208
|
+
for (const [p, item] of Object.entries<any>(doc.paths || {})) {
|
|
209
|
+
for (const m of METHODS) {
|
|
210
|
+
const opRaw = item?.[m]
|
|
211
|
+
if (!opRaw) continue
|
|
212
|
+
const op = deref(opRaw, doc)
|
|
213
|
+
const params = [...(deref(item.parameters, doc) || []), ...(op.parameters || [])].filter((x: any) => x && x.name)
|
|
214
|
+
const needs: string[] = []
|
|
215
|
+
let concrete = p
|
|
216
|
+
const query: Record<string, string> = {}
|
|
217
|
+
const headers: Record<string, string> = {}
|
|
218
|
+
let body: unknown
|
|
219
|
+
for (const prm of params) {
|
|
220
|
+
const sch = prm.schema || prm
|
|
221
|
+
const ex = prm.example ?? firstExample(prm) ?? sch.example ?? sch.default ?? sch.enum?.[0]
|
|
222
|
+
if (prm.in === "path") {
|
|
223
|
+
if (ex === undefined) needs.push(`path parameter ${prm.name}`)
|
|
224
|
+
concrete = concrete.replace(`{${prm.name}}`, encodeURIComponent(String(ex ?? exampleFor(toSchema(sch), 0, prm.name))))
|
|
225
|
+
} else if (prm.in === "query" && (prm.required || ex !== undefined)) {
|
|
226
|
+
if (ex === undefined) needs.push(`query parameter ${prm.name}`)
|
|
227
|
+
query[prm.name] = String(ex ?? exampleFor(toSchema(sch), 0, prm.name))
|
|
228
|
+
} else if (prm.in === "header" && prm.required && !/^(authorization|accept|content-type)$/i.test(prm.name)) {
|
|
229
|
+
if (ex === undefined) needs.push(`header ${prm.name}`)
|
|
230
|
+
headers[prm.name] = String(ex ?? "sample")
|
|
231
|
+
} else if (prm.in === "body" && prm.schema) {
|
|
232
|
+
body = prm.schema.example ?? exampleFor(toSchema(prm.schema))
|
|
233
|
+
headers["Content-Type"] = (op.consumes || doc.consumes || ["application/json"])[0]
|
|
234
|
+
if (prm.schema.example === undefined) needs.push("request body (made from the schema)")
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const reqMedia = jsonMedia(op.requestBody?.content)
|
|
238
|
+
if (reqMedia) {
|
|
239
|
+
const ex = firstExample(reqMedia[1])
|
|
240
|
+
body = ex ?? exampleFor(toSchema(reqMedia[1].schema))
|
|
241
|
+
headers["Content-Type"] = reqMedia[0]
|
|
242
|
+
if (ex === undefined && reqMedia[1].schema?.example === undefined) needs.push("request body (made from the schema)")
|
|
243
|
+
}
|
|
244
|
+
const codes = Object.keys(op.responses || {})
|
|
245
|
+
const ok = codes.filter((c) => /^2\d\d$/.test(c)).sort()[0] || (codes.includes("2XX") ? "200" : codes.includes("default") ? "default" : "")
|
|
246
|
+
const res = ok ? op.responses[ok] : undefined
|
|
247
|
+
const status = ok && /^\d+$/.test(ok) ? Number(ok) : 200
|
|
248
|
+
let schema: JsonSchema | undefined
|
|
249
|
+
let contentType: string | undefined
|
|
250
|
+
if (v2) {
|
|
251
|
+
schema = toSchema(res?.schema)
|
|
252
|
+
if (res?.schema) contentType = (op.produces || doc.produces || ["application/json"])[0]
|
|
253
|
+
} else {
|
|
254
|
+
const media = jsonMedia(res?.content)
|
|
255
|
+
if (media) {
|
|
256
|
+
contentType = media[0]
|
|
257
|
+
schema = toSchema(media[1].schema)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const fields = schemaFields(schema)
|
|
261
|
+
const method = m.toUpperCase()
|
|
262
|
+
const bodySchema = reqMedia?.[1]?.schema || params.find((x: any) => x.in === "body")?.schema
|
|
263
|
+
const requestRequired: string[] = Array.isArray(bodySchema?.required) ? bodySchema.required : []
|
|
264
|
+
const links = Object.values<any>(res?.links || {})
|
|
265
|
+
.filter((l) => l && typeof l.operationId === "string")
|
|
266
|
+
.map((l) => ({ operationId: String(l.operationId), parameters: Object.fromEntries(Object.entries<any>(l.parameters || {}).map(([k, v]) => [k.replace(/^(path|query|header)\./, ""), String(v)])) }))
|
|
267
|
+
contracts.push({
|
|
268
|
+
id: `${method} ${p}`,
|
|
269
|
+
protocol: "rest",
|
|
270
|
+
name: String(op.summary || op.operationId || `${method} ${p}`).slice(0, 120),
|
|
271
|
+
method,
|
|
272
|
+
path: p,
|
|
273
|
+
example: { path: concrete, ...(Object.keys(query).length ? { query } : {}), ...(Object.keys(headers).length ? { headers } : {}), ...(body !== undefined ? { body } : {}) },
|
|
274
|
+
expect: { status, ...(contentType ? { contentType } : {}), ...(schema && Object.keys(schema).length ? { schema } : {}), ...(fields.length ? { fields } : {}) },
|
|
275
|
+
...(authOf(op.security) ? { auth: authOf(op.security) } : {}),
|
|
276
|
+
writes: !["GET", "HEAD", "OPTIONS"].includes(method),
|
|
277
|
+
...(needs.length ? { needsData: needs } : {}),
|
|
278
|
+
source: { kind: v2 ? "swagger" : "openapi", ref: docUrl },
|
|
279
|
+
...(op.description || op.summary ? { description: String(op.description || op.summary).slice(0, 300) } : {}),
|
|
280
|
+
...(op.operationId ? { operationId: String(op.operationId) } : {}),
|
|
281
|
+
...(links.length ? { links } : {}),
|
|
282
|
+
...(requestRequired.length ? { requestRequired } : {}),
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!contracts.length) skipped.push("The description has no operations under paths.")
|
|
287
|
+
return { kind: v2 ? "swagger" : "openapi", name: String(doc.info?.title || "API"), baseUrl, contracts, skipped }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// -- WSDL (SOAP) --------------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
const asList = <T>(v: T | T[] | undefined): T[] => (v === undefined ? [] : Array.isArray(v) ? v : [v])
|
|
293
|
+
const local = (qname: unknown) => String(qname || "").split(":").pop() || ""
|
|
294
|
+
|
|
295
|
+
function fromWsdl(text: string, docUrl: string, _opts: LoadOptions): Loaded {
|
|
296
|
+
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@", removeNSPrefix: true })
|
|
297
|
+
const root = parser.parse(text)
|
|
298
|
+
const defs = root.definitions || root.description
|
|
299
|
+
if (!defs) throw new Error("Not a WSDL document: no definitions element.")
|
|
300
|
+
const tns = String(defs["@targetNamespace"] || "")
|
|
301
|
+
const soap12 = /soap12|w3\.org\/2003\/05\/soap/.test(text)
|
|
302
|
+
const schemas = asList<any>(defs.types?.schema)
|
|
303
|
+
const elements = new Map<string, any>()
|
|
304
|
+
const complex = new Map<string, any>()
|
|
305
|
+
for (const s of schemas) {
|
|
306
|
+
for (const e of asList<any>(s.element)) elements.set(e["@name"], e)
|
|
307
|
+
for (const c of asList<any>(s.complexType)) complex.set(c["@name"], c)
|
|
308
|
+
}
|
|
309
|
+
const children = (el: any): string[] => {
|
|
310
|
+
const ct = el?.complexType || (el?.["@type"] ? complex.get(local(el["@type"])) : undefined)
|
|
311
|
+
const seq = ct?.sequence || ct?.all || ct?.complexContent?.extension?.sequence
|
|
312
|
+
return asList<any>(seq?.element).map((c) => c["@name"] || local(c["@ref"])).filter(Boolean)
|
|
313
|
+
}
|
|
314
|
+
const messages = new Map<string, any>(asList<any>(defs.message).map((m) => [m["@name"], m]))
|
|
315
|
+
const location = asList<any>(defs.service).flatMap((s) => asList<any>(s.port || s.endpoint)).map((p) => p.address?.["@location"] || p["@address"]).find(Boolean) || ""
|
|
316
|
+
const binding = asList<any>(defs.binding)[0]
|
|
317
|
+
const actions = new Map<string, string>(asList<any>(binding?.operation).map((o) => [o["@name"], String(o.operation?.["@soapAction"] || o["@soapAction"] || "")]))
|
|
318
|
+
const portType = asList<any>(defs.portType || defs.interface)[0]
|
|
319
|
+
const contracts: Contract[] = []
|
|
320
|
+
const base = location ? new URL(location).origin : isUrl(docUrl) ? new URL(docUrl).origin : ""
|
|
321
|
+
const endpointPath = location ? new URL(location).pathname : "/"
|
|
322
|
+
for (const op of asList<any>(portType?.operation)) {
|
|
323
|
+
const name = String(op["@name"])
|
|
324
|
+
const inMsg = messages.get(local(op.input?.["@message"]))
|
|
325
|
+
const outMsg = messages.get(local(op.output?.["@message"]))
|
|
326
|
+
const inEl = local(asList<any>(inMsg?.part)[0]?.["@element"]) || local(op.input?.["@element"]) || name
|
|
327
|
+
const outEl = local(asList<any>(outMsg?.part)[0]?.["@element"]) || local(op.output?.["@element"]) || `${name}Response`
|
|
328
|
+
const kids = children(elements.get(inEl))
|
|
329
|
+
const envNs = soap12 ? "http://www.w3.org/2003/05/soap-envelope" : "http://schemas.xmlsoap.org/soap/envelope/"
|
|
330
|
+
const body = `<soap:Envelope xmlns:soap="${envNs}" xmlns:tns="${tns}"><soap:Header/><soap:Body><tns:${inEl}>${kids.map((k) => `<tns:${k}>?</tns:${k}>`).join("")}</tns:${inEl}></soap:Body></soap:Envelope>`
|
|
331
|
+
const action = actions.get(name) || ""
|
|
332
|
+
const headers: Record<string, string> = soap12 ? { "Content-Type": `application/soap+xml; charset=utf-8${action ? `; action="${action}"` : ""}` } : { "Content-Type": "text/xml; charset=utf-8", SOAPAction: `"${action}"` }
|
|
333
|
+
contracts.push({
|
|
334
|
+
id: `soap ${name}`,
|
|
335
|
+
protocol: "soap",
|
|
336
|
+
name,
|
|
337
|
+
method: "POST",
|
|
338
|
+
path: endpointPath,
|
|
339
|
+
example: { path: endpointPath, headers, body },
|
|
340
|
+
expect: { status: 200, contentType: soap12 ? "application/soap+xml" : "text/xml", elements: [outEl] },
|
|
341
|
+
writes: !READ_NAME.test(name),
|
|
342
|
+
...(kids.length ? { needsData: kids.map((k) => `element ${k}`) } : {}),
|
|
343
|
+
source: { kind: "wsdl", ref: docUrl },
|
|
344
|
+
...(op.documentation ? { description: String(op.documentation).slice(0, 300) } : {}),
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
return { kind: "wsdl", name: String(defs["@name"] || asList<any>(defs.service)[0]?.["@name"] || "SOAP service"), baseUrl: base, contracts, skipped: contracts.length ? [] : ["The WSDL has no operations."] }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// -- GraphQL ------------------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
type GqlTypeRef = { kind: string; name?: string | null; ofType?: GqlTypeRef | null }
|
|
353
|
+
type GqlField = { name: string; args?: { name: string; type: GqlTypeRef; defaultValue?: string | null }[]; type: GqlTypeRef }
|
|
354
|
+
type GqlType = { kind: string; name: string; fields?: GqlField[] | null; enumValues?: { name: string }[] | null; inputFields?: { name: string; type: GqlTypeRef }[] | null }
|
|
355
|
+
type GqlSchema = { queryType?: { name: string } | null; mutationType?: { name: string } | null; types: GqlType[] }
|
|
356
|
+
|
|
357
|
+
export const INTROSPECTION = `query IntrospectionQuery { __schema { queryType { name } mutationType { name } types { kind name fields(includeDeprecated: false) { name args { name defaultValue type { ...T } } type { ...T } } inputFields { name type { ...T } } enumValues { name } } } }
|
|
358
|
+
fragment T on __Type { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } } }`
|
|
359
|
+
|
|
360
|
+
async function graphqlEndpoint(url: string, opts: LoadOptions): Promise<Loaded> {
|
|
361
|
+
const res = await (opts.fetch || fetch)(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", ...(opts.headers || {}) }, body: JSON.stringify({ query: INTROSPECTION }) })
|
|
362
|
+
const json = (await res.json().catch(() => null)) as { data?: { __schema?: GqlSchema }; errors?: { message: string }[] } | null
|
|
363
|
+
if (!json?.data?.__schema) throw new Error(`${url} did not answer the GraphQL introspection query${json?.errors?.length ? `: ${json.errors[0].message}` : ` (HTTP ${res.status})`}. Introspection may be off on this server; give the schema as a file instead.`)
|
|
364
|
+
return fromIntrospection(json.data.__schema, url, url, opts)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const unwrap = (t: GqlTypeRef): { name: string; list: boolean; required: boolean } => {
|
|
368
|
+
let required = false
|
|
369
|
+
let list = false
|
|
370
|
+
let cur: GqlTypeRef | null | undefined = t
|
|
371
|
+
if (cur.kind === "NON_NULL") {
|
|
372
|
+
required = true
|
|
373
|
+
cur = cur.ofType
|
|
374
|
+
}
|
|
375
|
+
while (cur && (cur.kind === "NON_NULL" || cur.kind === "LIST")) {
|
|
376
|
+
if (cur.kind === "LIST") list = true
|
|
377
|
+
cur = cur.ofType
|
|
378
|
+
}
|
|
379
|
+
return { name: cur?.name || "", list, required }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const typeText = (t: GqlTypeRef): string => (t.kind === "NON_NULL" ? `${typeText(t.ofType!)}!` : t.kind === "LIST" ? `[${typeText(t.ofType!)}]` : t.name || "")
|
|
383
|
+
|
|
384
|
+
const SCALAR_SCHEMA: Record<string, string> = { Int: "integer", Float: "number", String: "string", Boolean: "boolean", ID: "string" }
|
|
385
|
+
|
|
386
|
+
function fromIntrospection(schema: GqlSchema, endpoint: string, ref: string, _opts: LoadOptions): Loaded {
|
|
387
|
+
const types = new Map(schema.types.map((t) => [t.name, t]))
|
|
388
|
+
const isLeaf = (name: string) => ["SCALAR", "ENUM"].includes(types.get(name)?.kind || "SCALAR")
|
|
389
|
+
const selection = (typeName: string, depth: number): string => {
|
|
390
|
+
const t = types.get(typeName)
|
|
391
|
+
if (!t?.fields) return ""
|
|
392
|
+
const leaves = t.fields.filter((f) => !f.args?.some((a) => a.type.kind === "NON_NULL") && isLeaf(unwrap(f.type).name)).slice(0, 12)
|
|
393
|
+
const nested = depth < 1 ? t.fields.filter((f) => !f.args?.some((a) => a.type.kind === "NON_NULL") && !isLeaf(unwrap(f.type).name)).slice(0, 3) : []
|
|
394
|
+
const parts = [...leaves.map((f) => f.name), ...nested.map((f) => `${f.name} ${selection(unwrap(f.type).name, depth + 1)}`.trim()).filter((s) => s.includes("{"))]
|
|
395
|
+
return parts.length ? `{ ${parts.join(" ")} }` : "{ __typename }"
|
|
396
|
+
}
|
|
397
|
+
const schemaOf = (typeName: string, list: boolean, depth: number): JsonSchema => {
|
|
398
|
+
const t = types.get(typeName)
|
|
399
|
+
let s: JsonSchema
|
|
400
|
+
if (!t || t.kind === "SCALAR") s = SCALAR_SCHEMA[typeName] ? { type: SCALAR_SCHEMA[typeName] } : {}
|
|
401
|
+
else if (t.kind === "ENUM") s = { type: "string", ...(t.enumValues?.length ? { enum: t.enumValues.map((e) => e.name) } : {}) }
|
|
402
|
+
else s = { type: "object" }
|
|
403
|
+
if (s.type === "object" && t?.fields && depth < 1) {
|
|
404
|
+
const leaves = t.fields.filter((f) => !f.args?.some((a) => a.type.kind === "NON_NULL") && isLeaf(unwrap(f.type).name)).slice(0, 12)
|
|
405
|
+
s.properties = Object.fromEntries(leaves.map((f) => [f.name, { ...schemaOf(unwrap(f.type).name, unwrap(f.type).list, depth + 1), ...(unwrap(f.type).required ? {} : { nullable: true }) }]))
|
|
406
|
+
}
|
|
407
|
+
return list ? { type: "array", items: s } : s
|
|
408
|
+
}
|
|
409
|
+
const exampleArg = (t: GqlTypeRef, name: string): unknown => {
|
|
410
|
+
const u = unwrap(t)
|
|
411
|
+
const it = types.get(u.name)
|
|
412
|
+
let v: unknown
|
|
413
|
+
if (it?.kind === "ENUM") v = it.enumValues?.[0]?.name
|
|
414
|
+
else if (it?.kind === "INPUT_OBJECT") v = Object.fromEntries((it.inputFields || []).filter((f) => f.type.kind === "NON_NULL").map((f) => [f.name, exampleArg(f.type, f.name)]))
|
|
415
|
+
else v = u.name === "Int" || u.name === "Float" ? 1 : u.name === "Boolean" ? true : u.name === "ID" ? "1" : /email/i.test(name) ? "user@example.com" : `sample-${name}`
|
|
416
|
+
return u.list ? [v] : v
|
|
417
|
+
}
|
|
418
|
+
const contracts: Contract[] = []
|
|
419
|
+
const endpointPath = isUrl(endpoint) ? new URL(endpoint).pathname : "/graphql"
|
|
420
|
+
for (const [rootName, kind] of [[schema.queryType?.name, "query"], [schema.mutationType?.name, "mutation"]] as const) {
|
|
421
|
+
const root = rootName ? types.get(rootName) : undefined
|
|
422
|
+
for (const f of root?.fields || []) {
|
|
423
|
+
if (f.name.startsWith("__")) continue
|
|
424
|
+
const required = (f.args || []).filter((a) => a.type.kind === "NON_NULL" && a.defaultValue == null)
|
|
425
|
+
const ret = unwrap(f.type)
|
|
426
|
+
const sel = isLeaf(ret.name) ? "" : ` ${selection(ret.name, 0)}`
|
|
427
|
+
const opName = f.name.charAt(0).toUpperCase() + f.name.slice(1)
|
|
428
|
+
const vars = required.map((a) => `$${a.name}: ${typeText(a.type)}`).join(", ")
|
|
429
|
+
const call = required.length ? `(${required.map((a) => `${a.name}: $${a.name}`).join(", ")})` : ""
|
|
430
|
+
const query = `${kind} ${opName}${vars ? `(${vars})` : ""} { ${f.name}${call}${sel} }`
|
|
431
|
+
const variables = Object.fromEntries(required.map((a) => [a.name, exampleArg(a.type, a.name)]))
|
|
432
|
+
const data: JsonSchema = { type: "object", required: ret.required ? [f.name] : [], properties: { [f.name]: { ...schemaOf(ret.name, ret.list, 0), ...(ret.required ? {} : { nullable: true }) } } }
|
|
433
|
+
contracts.push({
|
|
434
|
+
id: `graphql ${kind} ${opName}`,
|
|
435
|
+
protocol: "graphql",
|
|
436
|
+
name: f.name,
|
|
437
|
+
method: "POST",
|
|
438
|
+
path: endpointPath,
|
|
439
|
+
example: { path: endpointPath, headers: { "Content-Type": "application/json" }, body: { query, ...(required.length ? { variables } : {}), operationName: opName } },
|
|
440
|
+
expect: { status: 200, contentType: "application/json", schema: { type: "object", required: ["data"], properties: { data } }, fields: [{ path: `data.${f.name}` }] },
|
|
441
|
+
writes: kind === "mutation",
|
|
442
|
+
...(required.length ? { needsData: required.map((a) => `argument ${a.name}`) } : {}),
|
|
443
|
+
source: { kind: "graphql", ref },
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return { kind: "graphql", name: "GraphQL API", baseUrl: isUrl(endpoint) ? new URL(endpoint).origin : "", contracts, skipped: [] }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** GraphQL SDL as an introspection-like schema (types, fields, arguments), for the same contracts. */
|
|
451
|
+
export function sdlSchema(sdl: string): GqlSchema {
|
|
452
|
+
const text = sdl.replace(/#[^\n]*/g, "").replace(/"""[\s\S]*?"""/g, "").replace(/"[^"\n]*"/g, "")
|
|
453
|
+
const ref = (s: string): GqlTypeRef => {
|
|
454
|
+
const t = s.trim()
|
|
455
|
+
if (t.endsWith("!")) return { kind: "NON_NULL", ofType: ref(t.slice(0, -1)) }
|
|
456
|
+
if (t.startsWith("[")) return { kind: "LIST", ofType: ref(t.slice(1, t.lastIndexOf("]"))) }
|
|
457
|
+
return { kind: "NAMED", name: t }
|
|
458
|
+
}
|
|
459
|
+
const types: GqlType[] = []
|
|
460
|
+
const kinds = new Map<string, string>()
|
|
461
|
+
for (const m of text.matchAll(/\b(type|input|enum|interface|scalar)\s+(\w+)[^{\n]*({([^}]*)})?/g)) {
|
|
462
|
+
const [, kw, name, , body = ""] = m
|
|
463
|
+
const kind = kw === "type" || kw === "interface" ? "OBJECT" : kw === "input" ? "INPUT_OBJECT" : kw === "enum" ? "ENUM" : "SCALAR"
|
|
464
|
+
kinds.set(name, kind)
|
|
465
|
+
if (kind === "ENUM") {
|
|
466
|
+
types.push({ kind, name, enumValues: body.split(/\s+/).filter((v) => /^\w+$/.test(v)).map((n) => ({ name: n })) })
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
const fields: GqlField[] = []
|
|
470
|
+
for (const f of body.matchAll(/(\w+)\s*(\(([^)]*)\))?\s*:\s*([\w[\]!]+)/g)) {
|
|
471
|
+
const args = (f[3] || "")
|
|
472
|
+
.split(/,|\n/)
|
|
473
|
+
.map((a) => a.match(/(\w+)\s*:\s*([\w[\]!]+)(\s*=\s*(\S+))?/))
|
|
474
|
+
.filter(Boolean)
|
|
475
|
+
.map((a) => ({ name: a![1], type: ref(a![2]), defaultValue: a![4] ?? null }))
|
|
476
|
+
fields.push({ name: f[1], args, type: ref(f[4]) })
|
|
477
|
+
}
|
|
478
|
+
types.push(kind === "INPUT_OBJECT" ? { kind, name, inputFields: fields.map((f) => ({ name: f.name, type: f.type })) } : { kind, name, fields })
|
|
479
|
+
}
|
|
480
|
+
for (const s of ["String", "Int", "Float", "Boolean", "ID"]) if (!kinds.has(s)) types.push({ kind: "SCALAR", name: s })
|
|
481
|
+
const fix = (r: GqlTypeRef): GqlTypeRef => (r.kind === "NAMED" ? { kind: kinds.get(r.name!) || "SCALAR", name: r.name } : { ...r, ofType: r.ofType ? fix(r.ofType) : null })
|
|
482
|
+
for (const t of types) {
|
|
483
|
+
for (const f of t.fields || []) {
|
|
484
|
+
f.type = fix(f.type)
|
|
485
|
+
for (const a of f.args || []) a.type = fix(a.type)
|
|
486
|
+
}
|
|
487
|
+
for (const f of t.inputFields || []) f.type = fix(f.type)
|
|
488
|
+
}
|
|
489
|
+
const root = (name: string) => text.match(new RegExp(`\\b${name}\\s*:\\s*(\\w+)`))?.[1]
|
|
490
|
+
return { queryType: { name: root("query") || "Query" }, mutationType: { name: root("mutation") || "Mutation" }, types }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function fromSdl(sdl: string, ref: string, opts: LoadOptions): Loaded {
|
|
494
|
+
const loaded = fromIntrospection(sdlSchema(sdl), "", ref, opts)
|
|
495
|
+
return { ...loaded, skipped: ["The schema file gives no endpoint: set the base URL (and the endpoint path if it is not /graphql) to run these."] }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// -- gRPC ---------------------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
type ProtoMessage = { name: string; fields: { name: string; type: string; repeated: boolean }[] }
|
|
501
|
+
type ProtoService = { pkg: string; name: string; rpcs: { name: string; input: string; output: string; streaming: boolean }[] }
|
|
502
|
+
|
|
503
|
+
/** Each `keyword Name { ... }` block in the text, with braces matched (rpc option blocks and nested messages inside). */
|
|
504
|
+
function blocks(text: string, keyword: string): { name: string; body: string }[] {
|
|
505
|
+
const out: { name: string; body: string }[] = []
|
|
506
|
+
const re = new RegExp(`\\b${keyword}\\s+([\\w.]+)\\s*{`, "g")
|
|
507
|
+
for (const m of text.matchAll(re)) {
|
|
508
|
+
let depth = 1
|
|
509
|
+
let i = m.index! + m[0].length
|
|
510
|
+
const start = i
|
|
511
|
+
for (; i < text.length && depth; i++) depth += text[i] === "{" ? 1 : text[i] === "}" ? -1 : 0
|
|
512
|
+
out.push({ name: m[1], body: text.slice(start, i - 1) })
|
|
513
|
+
}
|
|
514
|
+
return out
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Services, rpcs, and messages from .proto text (or grpcurl describe output, which is written the same way). */
|
|
518
|
+
export function parseProto(text: string): { services: ProtoService[]; messages: Map<string, ProtoMessage> } {
|
|
519
|
+
const clean = text
|
|
520
|
+
.replace(/\/\/[^\n]*/g, "")
|
|
521
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
522
|
+
// grpcurl describe: "shop.Orders is a service:" then "service Orders {": keep the full name.
|
|
523
|
+
.replace(/^([\w.]+) is a service:\s*\n\s*service\s+\w+/gm, "service $1")
|
|
524
|
+
.replace(/^([\w.]+) is a message:\s*$/gm, "")
|
|
525
|
+
const pkg = clean.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ""
|
|
526
|
+
const services: ProtoService[] = []
|
|
527
|
+
const messages = new Map<string, ProtoMessage>()
|
|
528
|
+
for (const s of blocks(clean, "service")) {
|
|
529
|
+
const name = s.name.split(".").pop()!
|
|
530
|
+
const sp = s.name.includes(".") ? s.name.split(".").slice(0, -1).join(".") : pkg
|
|
531
|
+
const rpcs = [...s.body.matchAll(/\brpc\s+(\w+)\s*\(\s*(stream\s+)?\.?([\w.]+)\s*\)\s*returns\s*\(\s*(stream\s+)?\.?([\w.]+)\s*\)/g)].map((r) => ({ name: r[1], input: r[3], output: r[5], streaming: Boolean(r[2] || r[4]) }))
|
|
532
|
+
services.push({ pkg: sp, name, rpcs })
|
|
533
|
+
}
|
|
534
|
+
for (const m of blocks(clean, "message")) {
|
|
535
|
+
const own = m.body.replace(/\b(message|enum|oneof)\s+\w+\s*{[^{}]*}/g, (b) => (b.startsWith("oneof") ? b.replace(/^oneof\s+\w+\s*{|}$/g, "") : ""))
|
|
536
|
+
const fields = [...own.matchAll(/(repeated\s+|optional\s+)?\.?([\w.]+)\s+(\w+)\s*=\s*\d+/g)].map((f) => ({ name: f[3], type: f[2], repeated: f[1]?.trim() === "repeated" }))
|
|
537
|
+
const name = m.name.replace(/^\./, "")
|
|
538
|
+
messages.set(name.split(".").pop()!, { name, fields })
|
|
539
|
+
}
|
|
540
|
+
return { services, messages }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const PROTO_EXAMPLE: Record<string, unknown> = { string: "sample", bool: true, bytes: "", double: 1, float: 1, int32: 1, int64: "1", uint32: 1, uint64: "1", sint32: 1, sint64: "1", fixed32: 1, fixed64: "1" }
|
|
544
|
+
|
|
545
|
+
function protoExample(msg: ProtoMessage | undefined, messages: Map<string, ProtoMessage>, depth = 0): Record<string, unknown> {
|
|
546
|
+
if (!msg || depth > 3) return {}
|
|
547
|
+
return Object.fromEntries(
|
|
548
|
+
msg.fields.map((f) => {
|
|
549
|
+
const nested = messages.get(f.type.split(".").pop()!)
|
|
550
|
+
const v = f.type in PROTO_EXAMPLE ? PROTO_EXAMPLE[f.type] : nested ? protoExample(nested, messages, depth + 1) : f.name.endsWith("id") ? "1" : 0
|
|
551
|
+
return [f.name, f.repeated ? [v] : v]
|
|
552
|
+
}),
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function grpcContracts(services: ProtoService[], messages: Map<string, ProtoMessage>, ref: string, kind: SourceKind): { contracts: Contract[]; skipped: string[] } {
|
|
557
|
+
const contracts: Contract[] = []
|
|
558
|
+
const skipped: string[] = []
|
|
559
|
+
for (const s of services) {
|
|
560
|
+
if (/^grpc\.(reflection|health)/.test(s.pkg) || s.name === "ServerReflection") continue
|
|
561
|
+
for (const r of s.rpcs) {
|
|
562
|
+
const full = `${s.pkg ? `${s.pkg}.` : ""}${s.name}/${r.name}`
|
|
563
|
+
if (r.streaming) {
|
|
564
|
+
skipped.push(`${full}: streaming rpc (contracts cover unary calls)`)
|
|
565
|
+
continue
|
|
566
|
+
}
|
|
567
|
+
const input = messages.get(r.input.split(".").pop()!)
|
|
568
|
+
const output = messages.get(r.output.split(".").pop()!)
|
|
569
|
+
const schema: JsonSchema | undefined = output ? { type: "object", properties: Object.fromEntries(output.fields.map((f) => [f.name, f.repeated ? { type: "array" } : {}])) } : undefined
|
|
570
|
+
contracts.push({
|
|
571
|
+
id: `grpc ${full}`,
|
|
572
|
+
protocol: "grpc",
|
|
573
|
+
name: full,
|
|
574
|
+
method: "POST",
|
|
575
|
+
path: `/${full}`,
|
|
576
|
+
example: { path: `/${full}`, body: protoExample(input, messages) },
|
|
577
|
+
expect: { status: 0, ...(schema ? { schema } : {}) },
|
|
578
|
+
writes: !READ_NAME.test(r.name),
|
|
579
|
+
...(input?.fields.length ? { needsData: input.fields.map((f) => `field ${f.name}`) } : {}),
|
|
580
|
+
source: { kind, ref },
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return { contracts, skipped }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function fromProto(files: string[], _opts: LoadOptions, given?: string): Promise<Loaded> {
|
|
588
|
+
const text = given ?? files.map((f) => readFileSync(path.resolve(f), "utf8")).join("\n")
|
|
589
|
+
const { services, messages } = parseProto(text)
|
|
590
|
+
const { contracts, skipped } = grpcContracts(services, messages, files.join(", "), "proto")
|
|
591
|
+
if (!services.length) skipped.push("The .proto file has no service.")
|
|
592
|
+
return { kind: "proto", name: services.map((s) => s.name).join(", ") || "gRPC service", baseUrl: "", contracts, skipped: [...skipped, "A .proto file gives no server address: set the base URL to grpc://host:port (plaintext) or grpcs://host:port."] }
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export async function runGrpcurl(args: string[], input?: string): Promise<{ ok: boolean; out: string }> {
|
|
596
|
+
const bin = Bun.which("grpcurl")
|
|
597
|
+
if (!bin) throw new Error("gRPC needs grpcurl (https://github.com/fullstorydev/grpcurl) on PATH: brew install grpcurl, or go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest.")
|
|
598
|
+
const proc = Bun.spawn([bin, ...args], { stdin: input === undefined ? "ignore" : new Blob([input]), stdout: "pipe", stderr: "pipe" })
|
|
599
|
+
const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
|
|
600
|
+
return { ok: code === 0, out: `${out}${err}` }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function grpcTarget(url: string): { address: string; plaintext: boolean } {
|
|
604
|
+
const m = url.match(/^grpc(s?):\/\/([^/]+)/i)
|
|
605
|
+
if (!m) throw new Error(`Not a gRPC address: ${url} (use grpc://host:port or grpcs://host:port)`)
|
|
606
|
+
return { address: m[2], plaintext: !m[1] }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function grpcReflection(url: string, opts: LoadOptions): Promise<Loaded> {
|
|
610
|
+
const { address, plaintext } = grpcTarget(url)
|
|
611
|
+
const run = opts.grpcurl || runGrpcurl
|
|
612
|
+
const flags = [...(plaintext ? ["-plaintext"] : []), ...Object.entries(opts.headers || {}).flatMap(([k, v]) => ["-H", `${k}: ${v}`])]
|
|
613
|
+
const list = await run([...flags, address, "list"])
|
|
614
|
+
if (!list.ok) throw new Error(`grpcurl could not list the services at ${address}: ${list.out.trim().split("\n")[0]}. The server may not have reflection on; give its .proto files instead.`)
|
|
615
|
+
const names = list.out.split("\n").map((s) => s.trim()).filter((s) => s && !/^grpc\.reflection|^grpc\.health/.test(s))
|
|
616
|
+
let text = ""
|
|
617
|
+
for (const n of names) text += `\n${(await run([...flags, address, "describe", n])).out}`
|
|
618
|
+
const types = new Set([...text.matchAll(/\(\s*(?:stream\s+)?\.?([\w.]+)\s*\)/g)].map((m) => m[1]))
|
|
619
|
+
for (const t of types) text += `\n${(await run([...flags, address, "describe", t])).out}`
|
|
620
|
+
const { services, messages } = parseProto(text)
|
|
621
|
+
const { contracts, skipped } = grpcContracts(services, messages, url, "proto")
|
|
622
|
+
return { kind: "proto", name: names.join(", ") || "gRPC service", baseUrl: `${plaintext ? "grpc" : "grpcs"}://${address}`, contracts, skipped }
|
|
623
|
+
}
|
|
624
|
+
|