ty-fetch 0.0.1 → 0.0.2-beta.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 +172 -0
- package/base.d.ts +40 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +143 -0
- package/dist/core/ast-helpers.d.ts +8 -0
- package/dist/core/ast-helpers.js +107 -0
- package/dist/core/body-validator.d.ts +6 -0
- package/dist/core/body-validator.js +85 -0
- package/dist/core/index.d.ts +7 -0
- package/dist/core/index.js +23 -0
- package/dist/core/path-validator.d.ts +5 -0
- package/dist/core/path-validator.js +47 -0
- package/dist/core/schema-utils.d.ts +5 -0
- package/dist/core/schema-utils.js +29 -0
- package/dist/core/spec-cache.d.ts +20 -0
- package/dist/core/spec-cache.js +162 -0
- package/dist/core/types.d.ts +50 -0
- package/dist/core/types.js +2 -0
- package/dist/core/url-parser.d.ts +4 -0
- package/dist/core/url-parser.js +32 -0
- package/dist/generate-types.d.ts +69 -0
- package/dist/generate-types.js +271 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +632 -0
- package/dist/plugin/index.d.ts +7 -0
- package/dist/plugin/index.js +276 -0
- package/index.d.ts +1321 -0
- package/index.js +78 -2
- package/package.json +32 -5
- package/plugin.js +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# ty-fetch
|
|
2
|
+
|
|
3
|
+
TypeScript tooling that validates API calls against OpenAPI specs. Get autocomplete, diagnostics, and fully typed responses with zero manual types.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import tf from "ty-fetch";
|
|
7
|
+
|
|
8
|
+
const customers = await tf.get("https://api.stripe.com/v1/customers").json();
|
|
9
|
+
// customers is fully typed — data, has_more, object, url all autocomplete
|
|
10
|
+
|
|
11
|
+
tf.get("https://api.stripe.com/v1/cutsomers");
|
|
12
|
+
// ~~~~~~~~~~
|
|
13
|
+
// Error: Path '/v1/cutsomers' does not exist in Stripe API.
|
|
14
|
+
// Did you mean '/v1/customers'?
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
- **Path validation** — red squiggles for typos in API URLs, with "did you mean?" suggestions
|
|
20
|
+
- **Typed responses** — response types generated from OpenAPI schemas, no manual `as` casts
|
|
21
|
+
- **Typed request bodies** — body params validated against the spec
|
|
22
|
+
- **Path & query params** — typed `params.path` and `params.query` based on the endpoint
|
|
23
|
+
- **Autocomplete** — URL path completions inside string literals, filtered by HTTP method
|
|
24
|
+
- **Hover info** — hover over a URL to see available methods and descriptions
|
|
25
|
+
|
|
26
|
+
Works as both a **TS language service plugin** (editor DX) and a **CLI** (CI validation).
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install github:alnorris/ty-fetch
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Add the plugin to your `tsconfig.json`:
|
|
35
|
+
|
|
36
|
+
```jsonc
|
|
37
|
+
{
|
|
38
|
+
"compilerOptions": {
|
|
39
|
+
"plugins": [{ "name": "ty-fetch/plugin" }]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
In VS Code, make sure you're using the workspace TypeScript version (not the built-in one). Open the command palette and run **TypeScript: Select TypeScript Version** > **Use Workspace Version**.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
### The `ty-fetch` client
|
|
49
|
+
|
|
50
|
+
A lightweight HTTP client (similar to [ky](https://github.com/sindresorhus/ky)) with typed methods:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import tf from "ty-fetch";
|
|
54
|
+
|
|
55
|
+
// GET with typed response
|
|
56
|
+
const customers = await tf.get("https://api.stripe.com/v1/customers").json();
|
|
57
|
+
|
|
58
|
+
// POST with typed body
|
|
59
|
+
const customer = await tf.post("https://api.stripe.com/v1/customers", {
|
|
60
|
+
body: { name: "Jane Doe", email: "jane@example.com" },
|
|
61
|
+
}).json();
|
|
62
|
+
|
|
63
|
+
// Path params
|
|
64
|
+
const repo = await tf.get("https://api.github.com/repos/{owner}/{repo}", {
|
|
65
|
+
params: { path: { owner: "anthropics", repo: "claude-code" } },
|
|
66
|
+
}).json();
|
|
67
|
+
|
|
68
|
+
// Query params
|
|
69
|
+
const pets = await tf.get("https://petstore3.swagger.io/api/v3/pet/findByStatus", {
|
|
70
|
+
params: { query: { status: "available" } },
|
|
71
|
+
}).json();
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Response methods:
|
|
75
|
+
|
|
76
|
+
| Method | Returns |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `.json()` | `Promise<T>` (typed from spec) |
|
|
79
|
+
| `.text()` | `Promise<string>` |
|
|
80
|
+
| `.blob()` | `Promise<Blob>` |
|
|
81
|
+
| `.arrayBuffer()` | `Promise<ArrayBuffer>` |
|
|
82
|
+
| `await` directly | `T` (same as `.json()`) |
|
|
83
|
+
|
|
84
|
+
### CLI
|
|
85
|
+
|
|
86
|
+
Run validation in CI or from the terminal:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npx ty-fetch # uses ./tsconfig.json
|
|
90
|
+
npx ty-fetch tsconfig.json # explicit path
|
|
91
|
+
npx ty-fetch --verbose # show spec fetching details
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
example.ts:21:11 - error TF99001: Path '/v1/cutsomers' does not exist in Stripe API. Did you mean '/v1/customers'?
|
|
96
|
+
example.ts:57:11 - error TF99001: Path '/pets' does not exist in Swagger Petstore. Did you mean '/pet'?
|
|
97
|
+
|
|
98
|
+
2 error(s) found.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Custom specs
|
|
102
|
+
|
|
103
|
+
Map domains to local files or URLs in your tsconfig plugin config:
|
|
104
|
+
|
|
105
|
+
```jsonc
|
|
106
|
+
{
|
|
107
|
+
"compilerOptions": {
|
|
108
|
+
"plugins": [
|
|
109
|
+
{
|
|
110
|
+
"name": "ty-fetch/plugin",
|
|
111
|
+
"specs": {
|
|
112
|
+
"api.internal.company.com": "./specs/internal-api.json",
|
|
113
|
+
"api.partner.com": "https://partner.com/openapi.json"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- **File paths** are resolved relative to the tsconfig directory
|
|
122
|
+
- **URLs** are fetched over HTTPS
|
|
123
|
+
- Custom specs override built-in defaults for the same domain
|
|
124
|
+
|
|
125
|
+
This works in both the editor plugin and the CLI.
|
|
126
|
+
|
|
127
|
+
### Built-in specs
|
|
128
|
+
|
|
129
|
+
These APIs are supported out of the box (no config needed):
|
|
130
|
+
|
|
131
|
+
| Domain | API | Paths |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `api.stripe.com` | Stripe API | 414 |
|
|
134
|
+
| `petstore3.swagger.io` | Swagger Petstore | 13 |
|
|
135
|
+
| `api.github.com` | GitHub REST API | 551 |
|
|
136
|
+
|
|
137
|
+
## How it works
|
|
138
|
+
|
|
139
|
+
1. Plugin intercepts the TS language service (`getSemanticDiagnostics`, `getCompletionsAtPosition`, `getQuickInfoAtPosition`)
|
|
140
|
+
2. Finds `fetch()` / `tf.get()` / `tf.post()` etc. calls with string literal URLs
|
|
141
|
+
3. Extracts the domain and fetches the OpenAPI spec on-demand (cached after first fetch)
|
|
142
|
+
4. Validates paths against the spec, suggests corrections via Levenshtein distance
|
|
143
|
+
5. Generates typed overloads into `node_modules/ty-fetch/index.d.ts` using interface declaration merging — only for URLs actually used in your code
|
|
144
|
+
|
|
145
|
+
Spec fetching is async. On first encounter of a domain, the plugin fires a background fetch and returns no extra diagnostics. When the spec arrives, `refreshDiagnostics()` triggers the editor to re-check. This follows the same pattern as [graphqlsp](https://github.com/0no-co/graphqlsp).
|
|
146
|
+
|
|
147
|
+
## Architecture
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
src/
|
|
151
|
+
plugin/index.ts TS language service plugin (diagnostics, completions, hover)
|
|
152
|
+
cli/index.ts CLI entry point for CI validation
|
|
153
|
+
core/ Shared logic (URL parsing, spec cache, path matching, body validation)
|
|
154
|
+
generate-types.ts OpenAPI schema -> TypeScript type declarations
|
|
155
|
+
test-project/ Example project using the plugin
|
|
156
|
+
test/ Unit tests
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npm run build # compile TypeScript
|
|
163
|
+
npm run watch # compile in watch mode
|
|
164
|
+
npm test # run unit tests
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
To test the editor experience:
|
|
168
|
+
|
|
169
|
+
1. Open `test-project/` in VS Code
|
|
170
|
+
2. Select the workspace TypeScript version
|
|
171
|
+
3. Restart the TS server (`TypeScript: Restart TS Server`)
|
|
172
|
+
4. Edit `test-project/example.ts` and observe diagnostics/completions
|
package/base.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export class HTTPError extends Error {
|
|
2
|
+
response: Response;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface Options<
|
|
6
|
+
TBody = never,
|
|
7
|
+
TPathParams = never,
|
|
8
|
+
TQueryParams = never,
|
|
9
|
+
> extends Omit<RequestInit, 'body'> {
|
|
10
|
+
body?: TBody;
|
|
11
|
+
params?: {
|
|
12
|
+
path?: TPathParams;
|
|
13
|
+
query?: TQueryParams;
|
|
14
|
+
};
|
|
15
|
+
prefixUrl?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResponsePromise<T = unknown> extends PromiseLike<T> {
|
|
19
|
+
json(): Promise<T>;
|
|
20
|
+
text(): Promise<string>;
|
|
21
|
+
blob(): Promise<Blob>;
|
|
22
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
23
|
+
formData(): Promise<FormData>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TyFetch {
|
|
27
|
+
(url: string, options?: Options): ResponsePromise;
|
|
28
|
+
get(url: string, options?: Options): ResponsePromise;
|
|
29
|
+
post(url: string, options?: Options): ResponsePromise;
|
|
30
|
+
put(url: string, options?: Options): ResponsePromise;
|
|
31
|
+
patch(url: string, options?: Options): ResponsePromise;
|
|
32
|
+
delete(url: string, options?: Options): ResponsePromise;
|
|
33
|
+
head(url: string, options?: Options): ResponsePromise;
|
|
34
|
+
create(defaults?: Options<unknown>): TyFetch;
|
|
35
|
+
extend(defaults?: Options<unknown>): TyFetch;
|
|
36
|
+
HTTPError: typeof HTTPError;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
declare const tf: TyFetch;
|
|
40
|
+
export default tf;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
const ts = __importStar(require("typescript"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const core_1 = require("../core");
|
|
40
|
+
async function main() {
|
|
41
|
+
const args = process.argv.slice(2);
|
|
42
|
+
const tsconfigPath = args[0] ?? "tsconfig.json";
|
|
43
|
+
const configFile = ts.readConfigFile(path.resolve(tsconfigPath), ts.sys.readFile);
|
|
44
|
+
if (configFile.error) {
|
|
45
|
+
console.error("Error reading tsconfig:", ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(path.resolve(tsconfigPath)));
|
|
49
|
+
// Load custom spec overrides from tsconfig plugin config
|
|
50
|
+
const plugins = configFile.config?.compilerOptions?.plugins ?? [];
|
|
51
|
+
const pluginConfig = plugins.find((p) => p.name === "ty-fetch" || p.name === "ty-fetch/plugin");
|
|
52
|
+
if (pluginConfig?.specs) {
|
|
53
|
+
(0, core_1.registerSpecs)(pluginConfig.specs, path.dirname(path.resolve(tsconfigPath)));
|
|
54
|
+
}
|
|
55
|
+
const program = ts.createProgram(parsedConfig.fileNames, parsedConfig.options);
|
|
56
|
+
// Step 1: Collect all fetch URLs and their domains
|
|
57
|
+
const allCalls = [];
|
|
58
|
+
const domains = new Set();
|
|
59
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
60
|
+
if (sourceFile.isDeclarationFile)
|
|
61
|
+
continue;
|
|
62
|
+
const calls = (0, core_1.findFetchCalls)(ts, sourceFile);
|
|
63
|
+
for (const call of calls) {
|
|
64
|
+
allCalls.push({ file: sourceFile, call });
|
|
65
|
+
const parsed = (0, core_1.parseFetchUrl)(call.url);
|
|
66
|
+
if (parsed)
|
|
67
|
+
domains.add(parsed.domain);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (allCalls.length === 0) {
|
|
71
|
+
console.log("No fetch calls found.");
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
const log = (msg) => {
|
|
75
|
+
if (args.includes("--verbose"))
|
|
76
|
+
console.error(`[ty-fetch] ${msg}`);
|
|
77
|
+
};
|
|
78
|
+
// Step 2: Fetch all specs (async, in parallel)
|
|
79
|
+
log(`Found ${allCalls.length} fetch call(s) across ${domains.size} domain(s)`);
|
|
80
|
+
await Promise.all([...domains].map((d) => (0, core_1.fetchSpecForDomain)(d, log)));
|
|
81
|
+
// Step 3: Validate
|
|
82
|
+
const diagnostics = [];
|
|
83
|
+
for (const { file: sourceFile, call } of allCalls) {
|
|
84
|
+
const parsed = (0, core_1.parseFetchUrl)(call.url);
|
|
85
|
+
if (!parsed)
|
|
86
|
+
continue;
|
|
87
|
+
const entry = await (0, core_1.fetchSpecForDomain)(parsed.domain, log);
|
|
88
|
+
if (entry.status !== "loaded" || !entry.spec)
|
|
89
|
+
continue;
|
|
90
|
+
const apiPath = (0, core_1.stripBasePath)(parsed.path, entry.spec);
|
|
91
|
+
// Path validation
|
|
92
|
+
if (!(0, core_1.pathExistsInSpec)(apiPath, entry.spec)) {
|
|
93
|
+
const allPaths = Object.keys(entry.spec.paths);
|
|
94
|
+
const suggestion = (0, core_1.findClosestPath)(apiPath, allPaths);
|
|
95
|
+
const msg = `Path '${apiPath}' does not exist in ${entry.spec.info?.title ?? parsed.domain}.`
|
|
96
|
+
+ (suggestion ? ` Did you mean '${suggestion}'?` : "");
|
|
97
|
+
const pos = sourceFile.getLineAndCharacterOfPosition(call.urlStart);
|
|
98
|
+
diagnostics.push({
|
|
99
|
+
file: path.relative(process.cwd(), sourceFile.fileName),
|
|
100
|
+
line: pos.line + 1, col: pos.character + 1,
|
|
101
|
+
message: msg, code: 99001,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Body validation
|
|
105
|
+
if (call.httpMethod && call.jsonBody) {
|
|
106
|
+
const specPath = (0, core_1.findSpecPath)(apiPath, entry.spec);
|
|
107
|
+
if (specPath) {
|
|
108
|
+
const operation = entry.spec.paths[specPath]?.[call.httpMethod];
|
|
109
|
+
const reqSchema = operation?.requestBody?.content?.["application/json"]?.schema ??
|
|
110
|
+
operation?.requestBody?.content?.["application/x-www-form-urlencoded"]?.schema;
|
|
111
|
+
if (reqSchema) {
|
|
112
|
+
const resolved = (0, core_1.resolveSchemaRef)(reqSchema, entry.spec);
|
|
113
|
+
if (resolved?.properties) {
|
|
114
|
+
const jsonObjStart = call.jsonBody.length > 0 ? call.jsonBody[0].nameStart - 2 : call.callStart;
|
|
115
|
+
const bodyDiags = (0, core_1.validateJsonBody)(call.jsonBody, resolved, entry.spec, jsonObjStart);
|
|
116
|
+
for (const d of bodyDiags) {
|
|
117
|
+
const pos = sourceFile.getLineAndCharacterOfPosition(d.start);
|
|
118
|
+
diagnostics.push({
|
|
119
|
+
file: path.relative(process.cwd(), sourceFile.fileName),
|
|
120
|
+
line: pos.line + 1, col: pos.character + 1,
|
|
121
|
+
message: d.message, code: d.code,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Step 4: Output
|
|
130
|
+
if (diagnostics.length === 0) {
|
|
131
|
+
console.log("No errors found.");
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
for (const d of diagnostics) {
|
|
135
|
+
console.log(`${d.file}:${d.line}:${d.col} - error TF${d.code}: ${d.message}`);
|
|
136
|
+
}
|
|
137
|
+
console.log(`\n${diagnostics.length} error(s) found.`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
main().catch((err) => {
|
|
141
|
+
console.error("Fatal:", err);
|
|
142
|
+
process.exit(2);
|
|
143
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FetchCallInfo } from "./types";
|
|
2
|
+
type TS = typeof import("typescript");
|
|
3
|
+
type SourceFile = import("typescript").SourceFile;
|
|
4
|
+
/**
|
|
5
|
+
* Find all fetch()/tf.get()/api.post() calls in a source file.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findFetchCalls(ts: TS, sourceFile: SourceFile): FetchCallInfo[];
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findFetchCalls = findFetchCalls;
|
|
4
|
+
/**
|
|
5
|
+
* Find all fetch()/tf.get()/api.post() calls in a source file.
|
|
6
|
+
*/
|
|
7
|
+
function findFetchCalls(ts, sourceFile) {
|
|
8
|
+
const results = [];
|
|
9
|
+
function nodeStart(node) { return node.getStart(sourceFile); }
|
|
10
|
+
function nodeLen(node) { return node.getEnd() - nodeStart(node); }
|
|
11
|
+
function visit(node) {
|
|
12
|
+
if (ts.isCallExpression(node) && node.arguments.length > 0) {
|
|
13
|
+
const expr = node.expression;
|
|
14
|
+
let httpMethod = null;
|
|
15
|
+
if (ts.isIdentifier(expr) && (expr.text === "fetch" || expr.text === "typedFetch")) {
|
|
16
|
+
httpMethod = null;
|
|
17
|
+
}
|
|
18
|
+
else if (ts.isPropertyAccessExpression(expr) &&
|
|
19
|
+
ts.isIdentifier(expr.expression) &&
|
|
20
|
+
["get", "post", "put", "patch", "delete", "head", "request"].includes(expr.name.text)) {
|
|
21
|
+
httpMethod = expr.name.text === "request" || expr.name.text === "head" ? null : expr.name.text;
|
|
22
|
+
}
|
|
23
|
+
else if (ts.isIdentifier(expr)) {
|
|
24
|
+
httpMethod = null;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
ts.forEachChild(node, visit);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const arg = node.arguments[0];
|
|
31
|
+
if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
|
|
32
|
+
const urlStart = nodeStart(arg) + 1; // skip opening quote
|
|
33
|
+
const urlLength = nodeLen(arg) - 2; // exclude quotes
|
|
34
|
+
let jsonBody = null;
|
|
35
|
+
if (node.arguments.length >= 2) {
|
|
36
|
+
const optionsArg = node.arguments[1];
|
|
37
|
+
if (ts.isObjectLiteralExpression(optionsArg)) {
|
|
38
|
+
const jsonProp = optionsArg.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "body");
|
|
39
|
+
if (jsonProp && ts.isObjectLiteralExpression(jsonProp.initializer)) {
|
|
40
|
+
jsonBody = extractJsonProperties(ts, sourceFile, jsonProp.initializer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
results.push({
|
|
45
|
+
url: arg.text,
|
|
46
|
+
httpMethod,
|
|
47
|
+
urlStart,
|
|
48
|
+
urlLength,
|
|
49
|
+
callStart: nodeStart(node),
|
|
50
|
+
callLength: nodeLen(node),
|
|
51
|
+
jsonBody,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
ts.forEachChild(node, visit);
|
|
56
|
+
}
|
|
57
|
+
visit(sourceFile);
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
function extractJsonProperties(ts, sf, obj) {
|
|
61
|
+
function nodeStart(n) { return n.getStart(sf); }
|
|
62
|
+
function nodeLen(n) { return n.getEnd() - nodeStart(n); }
|
|
63
|
+
const props = [];
|
|
64
|
+
for (const prop of obj.properties) {
|
|
65
|
+
if (!ts.isPropertyAssignment(prop))
|
|
66
|
+
continue;
|
|
67
|
+
const name = ts.isIdentifier(prop.name) ? prop.name.text
|
|
68
|
+
: ts.isStringLiteral(prop.name) ? prop.name.text
|
|
69
|
+
: null;
|
|
70
|
+
if (!name)
|
|
71
|
+
continue;
|
|
72
|
+
const valueNode = prop.initializer;
|
|
73
|
+
let valueKind = "other";
|
|
74
|
+
let valueText = "";
|
|
75
|
+
if (ts.isNumericLiteral(valueNode)) {
|
|
76
|
+
valueKind = "number";
|
|
77
|
+
valueText = valueNode.text;
|
|
78
|
+
}
|
|
79
|
+
else if (ts.isStringLiteral(valueNode)) {
|
|
80
|
+
valueKind = "string";
|
|
81
|
+
valueText = valueNode.text;
|
|
82
|
+
}
|
|
83
|
+
else if (valueNode.kind === ts.SyntaxKind.TrueKeyword || valueNode.kind === ts.SyntaxKind.FalseKeyword) {
|
|
84
|
+
valueKind = "boolean";
|
|
85
|
+
valueText = valueNode.kind === ts.SyntaxKind.TrueKeyword ? "true" : "false";
|
|
86
|
+
}
|
|
87
|
+
else if (valueNode.kind === ts.SyntaxKind.NullKeyword) {
|
|
88
|
+
valueKind = "null";
|
|
89
|
+
}
|
|
90
|
+
else if (ts.isArrayLiteralExpression(valueNode)) {
|
|
91
|
+
valueKind = "array";
|
|
92
|
+
}
|
|
93
|
+
else if (ts.isObjectLiteralExpression(valueNode)) {
|
|
94
|
+
valueKind = "object";
|
|
95
|
+
}
|
|
96
|
+
props.push({
|
|
97
|
+
name,
|
|
98
|
+
nameStart: nodeStart(prop.name),
|
|
99
|
+
nameLength: nodeLen(prop.name),
|
|
100
|
+
valueStart: nodeStart(valueNode),
|
|
101
|
+
valueLength: nodeLen(valueNode),
|
|
102
|
+
valueText,
|
|
103
|
+
valueKind,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return props;
|
|
107
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { OpenAPISpec, JsonBodyProperty, ValidationDiagnostic } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Validate a JSON body object against an OpenAPI schema.
|
|
4
|
+
* Returns diagnostics with positions pointing at the specific offending properties.
|
|
5
|
+
*/
|
|
6
|
+
export declare function validateJsonBody(properties: JsonBodyProperty[], schema: any, spec: OpenAPISpec, jsonObjectStart: number): ValidationDiagnostic[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateJsonBody = validateJsonBody;
|
|
4
|
+
const schema_utils_1 = require("./schema-utils");
|
|
5
|
+
/**
|
|
6
|
+
* Validate a JSON body object against an OpenAPI schema.
|
|
7
|
+
* Returns diagnostics with positions pointing at the specific offending properties.
|
|
8
|
+
*/
|
|
9
|
+
function validateJsonBody(properties, schema, spec, jsonObjectStart) {
|
|
10
|
+
const diagnostics = [];
|
|
11
|
+
const schemaProps = schema.properties;
|
|
12
|
+
if (!schemaProps)
|
|
13
|
+
return diagnostics;
|
|
14
|
+
const requiredSet = new Set(schema.required ?? []);
|
|
15
|
+
// Check each property
|
|
16
|
+
for (const prop of properties) {
|
|
17
|
+
// Unknown property
|
|
18
|
+
if (!schemaProps[prop.name]) {
|
|
19
|
+
diagnostics.push({
|
|
20
|
+
start: prop.nameStart,
|
|
21
|
+
length: prop.nameLength,
|
|
22
|
+
message: `Property '${prop.name}' does not exist in the request body schema.`,
|
|
23
|
+
code: 99002,
|
|
24
|
+
});
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
// Type mismatch
|
|
28
|
+
const expectedSchema = (0, schema_utils_1.resolveSchemaRef)(schemaProps[prop.name], spec);
|
|
29
|
+
if (!expectedSchema?.type)
|
|
30
|
+
continue;
|
|
31
|
+
const mismatch = checkTypeMismatch(prop, expectedSchema);
|
|
32
|
+
if (mismatch) {
|
|
33
|
+
diagnostics.push({
|
|
34
|
+
start: prop.valueStart,
|
|
35
|
+
length: prop.valueLength,
|
|
36
|
+
message: mismatch,
|
|
37
|
+
code: 99003,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Missing required properties
|
|
42
|
+
const providedNames = new Set(properties.map((p) => p.name));
|
|
43
|
+
for (const reqProp of requiredSet) {
|
|
44
|
+
if (!providedNames.has(reqProp)) {
|
|
45
|
+
diagnostics.push({
|
|
46
|
+
start: jsonObjectStart,
|
|
47
|
+
length: 1, // opening brace
|
|
48
|
+
message: `Missing required property '${reqProp}' in request body.`,
|
|
49
|
+
code: 99004,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return diagnostics;
|
|
54
|
+
}
|
|
55
|
+
function checkTypeMismatch(prop, schema) {
|
|
56
|
+
const expectedType = schema.type;
|
|
57
|
+
const enumValues = schema.enum;
|
|
58
|
+
switch (prop.valueKind) {
|
|
59
|
+
case "number":
|
|
60
|
+
if (expectedType === "string")
|
|
61
|
+
return `Type 'number' is not assignable to type 'string'.`;
|
|
62
|
+
break;
|
|
63
|
+
case "string":
|
|
64
|
+
if (expectedType === "number" || expectedType === "integer")
|
|
65
|
+
return `Type 'string' is not assignable to type 'number'.`;
|
|
66
|
+
if (enumValues && !enumValues.includes(prop.valueText))
|
|
67
|
+
return `Value '${prop.valueText}' is not assignable to type '${enumValues.map((v) => `"${v}"`).join(" | ")}'.`;
|
|
68
|
+
break;
|
|
69
|
+
case "boolean":
|
|
70
|
+
if (expectedType === "string")
|
|
71
|
+
return `Type 'boolean' is not assignable to type 'string'.`;
|
|
72
|
+
if (expectedType === "number" || expectedType === "integer")
|
|
73
|
+
return `Type 'boolean' is not assignable to type 'number'.`;
|
|
74
|
+
break;
|
|
75
|
+
case "array":
|
|
76
|
+
if (expectedType !== "array")
|
|
77
|
+
return `Type 'array' is not assignable to type '${expectedType}'.`;
|
|
78
|
+
break;
|
|
79
|
+
case "object":
|
|
80
|
+
if (expectedType !== "object" && !schema.properties)
|
|
81
|
+
return `Type 'object' is not assignable to type '${expectedType}'.`;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./types"), exports);
|
|
18
|
+
__exportStar(require("./url-parser"), exports);
|
|
19
|
+
__exportStar(require("./path-validator"), exports);
|
|
20
|
+
__exportStar(require("./schema-utils"), exports);
|
|
21
|
+
__exportStar(require("./body-validator"), exports);
|
|
22
|
+
__exportStar(require("./spec-cache"), exports);
|
|
23
|
+
__exportStar(require("./ast-helpers"), exports);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OpenAPISpec } from "./types";
|
|
2
|
+
export declare function matchesPathTemplate(actualPath: string, templatePath: string): boolean;
|
|
3
|
+
export declare function pathExistsInSpec(path: string, spec: OpenAPISpec): boolean;
|
|
4
|
+
export declare function findSpecPath(apiPath: string, spec: OpenAPISpec): string | null;
|
|
5
|
+
export declare function findClosestPath(target: string, paths: string[]): string | null;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.matchesPathTemplate = matchesPathTemplate;
|
|
4
|
+
exports.pathExistsInSpec = pathExistsInSpec;
|
|
5
|
+
exports.findSpecPath = findSpecPath;
|
|
6
|
+
exports.findClosestPath = findClosestPath;
|
|
7
|
+
function matchesPathTemplate(actualPath, templatePath) {
|
|
8
|
+
const actualParts = actualPath.split("/");
|
|
9
|
+
const templateParts = templatePath.split("/");
|
|
10
|
+
if (actualParts.length !== templateParts.length)
|
|
11
|
+
return false;
|
|
12
|
+
return templateParts.every((tp, i) => tp.startsWith("{") || tp === actualParts[i]);
|
|
13
|
+
}
|
|
14
|
+
function pathExistsInSpec(path, spec) {
|
|
15
|
+
if (spec.paths[path])
|
|
16
|
+
return true;
|
|
17
|
+
return Object.keys(spec.paths).some((tp) => matchesPathTemplate(path, tp));
|
|
18
|
+
}
|
|
19
|
+
function findSpecPath(apiPath, spec) {
|
|
20
|
+
if (spec.paths[apiPath])
|
|
21
|
+
return apiPath;
|
|
22
|
+
return Object.keys(spec.paths).find((tp) => matchesPathTemplate(apiPath, tp)) ?? null;
|
|
23
|
+
}
|
|
24
|
+
function findClosestPath(target, paths) {
|
|
25
|
+
let best = null;
|
|
26
|
+
let bestDist = Infinity;
|
|
27
|
+
for (const p of paths) {
|
|
28
|
+
const d = levenshtein(target, p);
|
|
29
|
+
if (d < bestDist && d <= Math.max(target.length, p.length) * 0.4) {
|
|
30
|
+
bestDist = d;
|
|
31
|
+
best = p;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return best;
|
|
35
|
+
}
|
|
36
|
+
function levenshtein(a, b) {
|
|
37
|
+
const m = a.length, n = b.length;
|
|
38
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
39
|
+
for (let i = 0; i <= m; i++)
|
|
40
|
+
dp[i][0] = i;
|
|
41
|
+
for (let j = 0; j <= n; j++)
|
|
42
|
+
dp[0][j] = j;
|
|
43
|
+
for (let i = 1; i <= m; i++)
|
|
44
|
+
for (let j = 1; j <= n; j++)
|
|
45
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
46
|
+
return dp[m][n];
|
|
47
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OpenAPISpec } from "./types";
|
|
2
|
+
export declare function resolveSchemaRef(schema: any, spec: OpenAPISpec): any;
|
|
3
|
+
export declare function getRequestBodySchema(operation: any): any | null;
|
|
4
|
+
export declare function getResponseSchema(operation: any): any | null;
|
|
5
|
+
export declare function isRequestBodyRequired(operation: any): boolean;
|