ty-fetch 0.0.2-beta.1 → 0.0.2-beta.11

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alister Norris
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,37 +1,70 @@
1
- # ty-fetch
1
+ # ty-fetch
2
2
 
3
- TypeScript tooling that validates API calls against OpenAPI specs. Get autocomplete, diagnostics, and fully typed responses with zero manual types.
3
+ [![npm version](https://img.shields.io/npm/v/ty-fetch.svg)](https://www.npmjs.com/package/ty-fetch)
4
+ [![license](https://img.shields.io/npm/l/ty-fetch.svg)](https://github.com/alnorris/ty-fetch/blob/main/LICENSE)
5
+ [![CI](https://github.com/alnorris/ty-fetch/actions/workflows/ci.yml/badge.svg)](https://github.com/alnorris/ty-fetch/actions/workflows/ci.yml)
6
+
7
+ **Type-safe fetch from OpenAPI specs. No codegen, no build step.**
8
+
9
+ ```bash
10
+ npm install ty-fetch
11
+ ```
12
+
13
+ ```jsonc
14
+ // tsconfig.json — that's the entire setup
15
+ {
16
+ "compilerOptions": {
17
+ "plugins": [{ "name": "ty-fetch/plugin" }]
18
+ }
19
+ }
20
+ ```
4
21
 
5
22
  ```ts
6
23
  import tf from "ty-fetch";
7
24
 
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'?
25
+ const data = await tf.get("https://api.mycompany.com/v1/users").json();
26
+ // ^ fully typed from your OpenAPI spec autocomplete, hover docs, everything
15
27
  ```
16
28
 
17
- ## What it does
29
+ If your API serves an OpenAPI spec at `/openapi.json` (or any well-known path), ty-fetch finds it automatically. No config, no codegen, no generated files. Just types.
30
+
31
+ ---
32
+
33
+ ## 🤔 How does it work?
34
+
35
+ ty-fetch is a **TypeScript language service plugin**. When you write a `tf.get("https://...")` call:
36
+
37
+ 1. 🔍 It extracts the domain from the URL
38
+ 2. 📡 Fetches the OpenAPI spec (checks `/openapi.json`, `/.well-known/openapi.yaml`, etc.)
39
+ 3. 🏗️ Generates typed overloads on-the-fly — response types, query params, headers, everything
40
+ 4. ✅ Validates your API paths and suggests corrections for typos
41
+
42
+ Types appear in your editor instantly. When the spec changes, types update automatically. No build step ever.
43
+
44
+ ### Compared to other tools
18
45
 
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
46
+ | | ty-fetch | [openapi-typescript](https://github.com/openapi-ts/openapi-typescript) | [openapi-fetch](https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch) | [orval](https://github.com/orval-labs/orval) |
47
+ |---|---|---|---|---|
48
+ | **Codegen step** | None | `npx openapi-typescript ...` | Needs openapi-typescript first | `npx orval` |
49
+ | **Build step** | None | Required | Required | Required |
50
+ | **Generated files** | None | `.d.ts` files | `.d.ts` files | Full client |
51
+ | **Spec changes** | Auto-updates | Re-run codegen | Re-run codegen | Re-run codegen |
52
+ | **Editor integration** | TS plugin (autocomplete, hover, diagnostics) | Types only | Types only | Types only |
53
+ | **Path validation** | Typo detection with suggestions | None | None | None |
54
+ | **Auto-discovery** | Probes well-known paths | Manual | Manual | Manual |
55
+ | **Runtime** | Lightweight fetch wrapper | None (types only) | Fetch wrapper | Axios/fetch client |
25
56
 
26
- Works as both a **TS language service plugin** (editor DX) and a **CLI** (CI validation).
57
+ ---
27
58
 
28
- ## Setup
59
+ ## 📦 Quick start
60
+
61
+ ### 1. Install
29
62
 
30
63
  ```bash
31
- npm install github:alnorris/ty-fetch
64
+ npm install ty-fetch
32
65
  ```
33
66
 
34
- Add the plugin to your `tsconfig.json`:
67
+ ### 2. Add the plugin to tsconfig.json
35
68
 
36
69
  ```jsonc
37
70
  {
@@ -41,66 +74,43 @@ Add the plugin to your `tsconfig.json`:
41
74
  }
42
75
  ```
43
76
 
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
77
+ ### 3. Use workspace TypeScript in VS Code
47
78
 
48
- ### The `ty-fetch` client
79
+ **Command Palette** **TypeScript: Select TypeScript Version** → **Use Workspace Version**
49
80
 
50
- A lightweight HTTP client (similar to [ky](https://github.com/sindresorhus/ky)) with typed methods:
81
+ ### 4. Start fetching
51
82
 
52
83
  ```ts
53
84
  import tf from "ty-fetch";
54
85
 
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();
86
+ // If your API has a spec at /openapi.json — types just work
87
+ const users = await tf.get("https://api.mycompany.com/v1/users").json();
72
88
  ```
73
89
 
74
- Response methods:
90
+ That's it. ✨
75
91
 
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()`) |
92
+ ---
83
93
 
84
- ### CLI
94
+ ## 🔍 Spec discovery
85
95
 
86
- Run validation in CI or from the terminal:
96
+ ### Auto-discovery (zero config)
87
97
 
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
- ```
98
+ When you call `tf.get("https://api.example.com/...")`, ty-fetch automatically probes the domain for an OpenAPI spec at these well-known paths:
93
99
 
94
100
  ```
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.
101
+ /.well-known/openapi.json /.well-known/openapi.yaml
102
+ /openapi.json /openapi.yaml
103
+ /api/openapi.json /docs/openapi.json
104
+ /swagger.json /api-docs/openapi.json
99
105
  ```
100
106
 
101
- ## Custom specs
107
+ If any path returns a valid OpenAPI spec, types are generated automatically.
108
+
109
+ **This means if your internal API serves a spec, ty-fetch will find it with zero configuration.**
102
110
 
103
- Map domains to local files or URLs in your tsconfig plugin config:
111
+ ### Point to specific specs
112
+
113
+ For APIs that don't serve specs at standard paths, or for local spec files:
104
114
 
105
115
  ```jsonc
106
116
  {
@@ -109,7 +119,13 @@ Map domains to local files or URLs in your tsconfig plugin config:
109
119
  {
110
120
  "name": "ty-fetch/plugin",
111
121
  "specs": {
112
- "api.internal.company.com": "./specs/internal-api.json",
122
+ // Remote spec URL
123
+ "api.mycompany.com": "https://api.mycompany.com/docs/v2/openapi.json",
124
+
125
+ // Local file (resolved relative to tsconfig)
126
+ "payments.internal.com": "./specs/payments.yaml",
127
+
128
+ // Third-party API
113
129
  "api.partner.com": "https://partner.com/openapi.json"
114
130
  }
115
131
  }
@@ -118,55 +134,103 @@ Map domains to local files or URLs in your tsconfig plugin config:
118
134
  }
119
135
  ```
120
136
 
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
137
+ JSON and YAML specs both supported. Custom specs override auto-discovery for the same domain.
124
138
 
125
- This works in both the editor plugin and the CLI.
139
+ ---
126
140
 
127
- ### Built-in specs
141
+ ## 🔧 Usage
128
142
 
129
- These APIs are supported out of the box (no config needed):
143
+ ```ts
144
+ import tf from "ty-fetch";
130
145
 
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 |
146
+ // 📥 GET response is fully typed
147
+ const users = await tf.get("https://api.mycompany.com/v1/users").json();
136
148
 
137
- ## How it works
149
+ // 📤 POST — body is validated against the spec
150
+ const user = await tf.post("https://api.mycompany.com/v1/users", {
151
+ body: { name: "Jane Doe", email: "jane@example.com" },
152
+ }).json();
138
153
 
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
154
+ // 🔗 Path params typed from the spec's {param} placeholders
155
+ const user = await tf.get("https://api.mycompany.com/v1/users/{id}", {
156
+ params: { path: { id: "123" } },
157
+ }).json();
158
+
159
+ // 🔍 Query params — typed from the spec's parameter definitions
160
+ const results = await tf.get("https://api.mycompany.com/v1/users", {
161
+ params: { query: { role: "admin", limit: 10 } },
162
+ }).json();
144
163
 
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).
164
+ // 🔑 Headers required API keys typed from security schemes
165
+ const data = await tf.get("https://api.mycompany.com/v1/data", {
166
+ headers: { "x-api-key": process.env.API_KEY },
167
+ }).json();
168
+ ```
146
169
 
147
- ## Architecture
170
+ ### Response methods
148
171
 
172
+ | Method | Returns |
173
+ |---|---|
174
+ | `.json()` | `Promise<T>` — typed from spec |
175
+ | `.text()` | `Promise<string>` |
176
+ | `.blob()` | `Promise<Blob>` |
177
+ | `.arrayBuffer()` | `Promise<ArrayBuffer>` |
178
+ | `await` directly | `T` — same as `.json()` |
179
+
180
+ ---
181
+
182
+ ## 🚀 Features
183
+
184
+ - 🔮 **Zero codegen** — types generated on-the-fly by a TS plugin, not a build step
185
+ - 🔍 **Auto-discovery** — finds OpenAPI specs at well-known paths automatically
186
+ - 📦 **Typed responses** — `.json()` returns the actual response type
187
+ - ✏️ **Typed request bodies** — body params validated against the schema
188
+ - 🔗 **Typed path & query params** — based on the endpoint definition
189
+ - 🔑 **Typed headers** — required API keys from security schemes
190
+ - 🚨 **Path validation** — red squiggles for typos, with "did you mean?" suggestions
191
+ - 💡 **Autocomplete** — URL path completions inside string literals
192
+ - 📖 **JSDoc descriptions** — property descriptions from the spec in hover tooltips
193
+ - 📄 **YAML + JSON** — specs can be either format, local files or remote URLs
194
+ - 🧠 **Example inference** — generates types from response `example` when `schema` is missing
195
+ - ⚡ **On-demand** — only fetches specs for APIs you actually call in your code
196
+
197
+ ---
198
+
199
+ ## 🖥️ CLI
200
+
201
+ Validate API calls in CI — catches typos and bad paths without running the app:
202
+
203
+ ```bash
204
+ npx ty-fetch # uses ./tsconfig.json
205
+ npx ty-fetch tsconfig.json # explicit path
206
+ npx ty-fetch --verbose # show spec fetching details
149
207
  ```
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
208
+
157
209
  ```
210
+ src/api.ts:21:11 - error TF99001: Path '/v1/uusers' does not exist.
211
+ Did you mean '/v1/users'?
158
212
 
159
- ## Development
213
+ 1 error(s) found.
214
+ ```
215
+
216
+ ---
217
+
218
+ ## 🧪 Development
160
219
 
161
220
  ```bash
162
221
  npm run build # compile TypeScript
163
222
  npm run watch # compile in watch mode
164
- npm test # run unit tests
223
+ npm test # run unit tests (74 tests)
165
224
  ```
166
225
 
167
- To test the editor experience:
226
+ ## Roadmap
227
+
228
+ - [ ] Record demo GIF showing autocomplete + typo detection in VS Code
229
+ - [ ] Spec caching to disk (avoid re-fetching on TS server restart)
230
+ - [ ] `ty-fetch generate` CLI command to generate `.d.ts` for `tsc` compatibility
231
+ - [ ] Support OpenAPI 2.0 (Swagger) specs
232
+ - [ ] Request/response interceptors and middleware
233
+
234
+ ## License
168
235
 
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
236
+ MIT
package/base.d.ts CHANGED
@@ -6,12 +6,14 @@ export interface Options<
6
6
  TBody = never,
7
7
  TPathParams = never,
8
8
  TQueryParams = never,
9
- > extends Omit<RequestInit, 'body'> {
9
+ THeaders extends Record<string, string> = Record<string, string>,
10
+ > extends Omit<RequestInit, 'body' | 'headers'> {
10
11
  body?: TBody;
11
12
  params?: {
12
13
  path?: TPathParams;
13
14
  query?: TQueryParams;
14
15
  };
16
+ headers?: THeaders & Record<string, string>;
15
17
  prefixUrl?: string;
16
18
  }
17
19
 
@@ -23,16 +25,19 @@ export interface ResponsePromise<T = unknown> extends PromiseLike<T> {
23
25
  formData(): Promise<FormData>;
24
26
  }
25
27
 
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ type BaseOptions = Options<any, Record<string, any>, Record<string, any>>;
30
+
26
31
  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;
32
+ (url: string, options?: BaseOptions): ResponsePromise<any>;
33
+ get(url: string, options?: BaseOptions): ResponsePromise<any>;
34
+ post(url: string, options?: BaseOptions): ResponsePromise<any>;
35
+ put(url: string, options?: BaseOptions): ResponsePromise<any>;
36
+ patch(url: string, options?: BaseOptions): ResponsePromise<any>;
37
+ delete(url: string, options?: BaseOptions): ResponsePromise<any>;
38
+ head(url: string, options?: BaseOptions): ResponsePromise<any>;
39
+ create(defaults?: BaseOptions): TyFetch;
40
+ extend(defaults?: BaseOptions): TyFetch;
36
41
  HTTPError: typeof HTTPError;
37
42
  }
38
43
 
package/dist/cli/index.js CHANGED
@@ -39,6 +39,16 @@ const path = __importStar(require("path"));
39
39
  const core_1 = require("../core");
40
40
  async function main() {
41
41
  const args = process.argv.slice(2);
42
+ if (args.includes("--help") || args.includes("-h")) {
43
+ console.log(`Usage: ty-fetch [tsconfig.json] [--verbose]
44
+
45
+ Validate API calls against OpenAPI specs.
46
+
47
+ Options:
48
+ --verbose Show spec fetching details
49
+ --help, -h Show this help message`);
50
+ process.exit(0);
51
+ }
42
52
  const tsconfigPath = args[0] ?? "tsconfig.json";
43
53
  const configFile = ts.readConfigFile(path.resolve(tsconfigPath), ts.sys.readFile);
44
54
  if (configFile.error) {
@@ -32,17 +32,17 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  exports.specCache = exports.KNOWN_SPECS = void 0;
37
40
  exports.registerSpecs = registerSpecs;
38
41
  exports.ensureSpec = ensureSpec;
39
42
  exports.ensureSpecSync = ensureSpecSync;
40
43
  exports.fetchSpecForDomain = fetchSpecForDomain;
41
- exports.KNOWN_SPECS = {
42
- "api.stripe.com": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
43
- "petstore3.swagger.io": "https://petstore3.swagger.io/api/v3/openapi.json",
44
- "api.github.com": "https://api.apis.guru/v2/specs/github.com/api.github.com/1.1.4/openapi.json",
45
- };
44
+ const js_yaml_1 = __importDefault(require("js-yaml"));
45
+ exports.KNOWN_SPECS = {};
46
46
  exports.specCache = new Map();
47
47
  /**
48
48
  * Register user-provided spec mappings (from tsconfig plugin config).
@@ -73,26 +73,43 @@ function ensureSpec(domain, log, onLoaded) {
73
73
  if (existing)
74
74
  return existing;
75
75
  const specUrl = exports.KNOWN_SPECS[domain];
76
- if (!specUrl) {
77
- const entry = { status: "not-found", spec: null, fetchedAt: Date.now() };
78
- exports.specCache.set(domain, entry);
79
- return entry;
80
- }
81
76
  const entry = { status: "loading", spec: null, fetchedAt: Date.now() };
82
77
  exports.specCache.set(domain, entry);
83
- log(`Fetching spec for ${domain} from ${specUrl}`);
84
- fetchSpec(specUrl)
85
- .then((spec) => {
86
- entry.status = "loaded";
87
- entry.spec = spec;
88
- const pathCount = Object.keys(spec.paths || {}).length;
89
- log(`Spec loaded for ${domain}: ${spec.info?.title ?? "unknown"} (${pathCount} paths)`);
90
- onLoaded?.(domain, spec);
91
- })
92
- .catch((err) => {
93
- entry.status = "not-found";
94
- log(`Failed to fetch spec for ${domain}: ${err}`);
95
- });
78
+ if (specUrl) {
79
+ log(`Fetching spec for ${domain} from ${specUrl}`);
80
+ fetchSpec(specUrl)
81
+ .then((spec) => {
82
+ entry.status = "loaded";
83
+ entry.spec = spec;
84
+ const pathCount = Object.keys(spec.paths || {}).length;
85
+ log(`Spec loaded for ${domain}: ${spec.info?.title ?? "unknown"} (${pathCount} paths)`);
86
+ onLoaded?.(domain, spec);
87
+ })
88
+ .catch((err) => {
89
+ entry.status = "not-found";
90
+ log(`Failed to fetch spec for ${domain}: ${err}`);
91
+ });
92
+ }
93
+ else {
94
+ log(`No spec configured for ${domain}, probing well-known URLs...`);
95
+ probeWellKnownSpecs(domain, log)
96
+ .then((spec) => {
97
+ if (spec) {
98
+ entry.status = "loaded";
99
+ entry.spec = spec;
100
+ const pathCount = Object.keys(spec.paths || {}).length;
101
+ log(`Spec discovered for ${domain}: ${spec.info?.title ?? "unknown"} (${pathCount} paths)`);
102
+ onLoaded?.(domain, spec);
103
+ }
104
+ else {
105
+ entry.status = "not-found";
106
+ log(`No spec found for ${domain} at well-known URLs`);
107
+ }
108
+ })
109
+ .catch(() => {
110
+ entry.status = "not-found";
111
+ });
112
+ }
96
113
  return entry;
97
114
  }
98
115
  /**
@@ -110,33 +127,86 @@ async function fetchSpecForDomain(domain, log) {
110
127
  if (existing && existing.status === "loaded")
111
128
  return existing;
112
129
  const specUrl = exports.KNOWN_SPECS[domain];
113
- if (!specUrl) {
114
- const entry = { status: "not-found", spec: null, fetchedAt: Date.now() };
115
- exports.specCache.set(domain, entry);
116
- return entry;
130
+ if (specUrl) {
131
+ log(`Fetching spec for ${domain} from ${specUrl}`);
132
+ try {
133
+ const spec = await fetchSpec(specUrl);
134
+ const entry = { status: "loaded", spec, fetchedAt: Date.now() };
135
+ exports.specCache.set(domain, entry);
136
+ const pathCount = Object.keys(spec.paths || {}).length;
137
+ log(`Spec loaded for ${domain}: ${spec.info?.title ?? "unknown"} (${pathCount} paths)`);
138
+ return entry;
139
+ }
140
+ catch (err) {
141
+ const entry = { status: "not-found", spec: null, fetchedAt: Date.now() };
142
+ exports.specCache.set(domain, entry);
143
+ log(`Failed to fetch spec for ${domain}: ${err}`);
144
+ return entry;
145
+ }
117
146
  }
118
- log(`Fetching spec for ${domain} from ${specUrl}`);
119
- try {
120
- const spec = await fetchSpec(specUrl);
147
+ log(`No spec configured for ${domain}, probing well-known URLs...`);
148
+ const spec = await probeWellKnownSpecs(domain, log);
149
+ if (spec) {
121
150
  const entry = { status: "loaded", spec, fetchedAt: Date.now() };
122
151
  exports.specCache.set(domain, entry);
123
152
  const pathCount = Object.keys(spec.paths || {}).length;
124
- log(`Spec loaded for ${domain}: ${spec.info?.title ?? "unknown"} (${pathCount} paths)`);
153
+ log(`Spec discovered for ${domain}: ${spec.info?.title ?? "unknown"} (${pathCount} paths)`);
125
154
  return entry;
126
155
  }
127
- catch (err) {
128
- const entry = { status: "not-found", spec: null, fetchedAt: Date.now() };
129
- exports.specCache.set(domain, entry);
130
- log(`Failed to fetch spec for ${domain}: ${err}`);
131
- return entry;
156
+ const entry = { status: "not-found", spec: null, fetchedAt: Date.now() };
157
+ exports.specCache.set(domain, entry);
158
+ log(`No spec found for ${domain} at well-known URLs`);
159
+ return entry;
160
+ }
161
+ const WELL_KNOWN_PATHS = [
162
+ "/.well-known/openapi.json",
163
+ "/.well-known/openapi.yaml",
164
+ "/.well-known/openapi.yml",
165
+ "/openapi.json",
166
+ "/openapi.yaml",
167
+ "/openapi.yml",
168
+ "/api/openapi.json",
169
+ "/api/openapi.yaml",
170
+ "/docs/openapi.json",
171
+ "/docs/openapi.yaml",
172
+ "/swagger.json",
173
+ "/api-docs/openapi.json",
174
+ ];
175
+ async function probeWellKnownSpecs(domain, log) {
176
+ // Try HTTPS first, then HTTP for local/dev servers
177
+ const protocols = domain.startsWith("127.0.0.1") || domain.startsWith("localhost")
178
+ ? ["http"] : ["https", "http"];
179
+ for (const proto of protocols) {
180
+ for (const path of WELL_KNOWN_PATHS) {
181
+ const url = `${proto}://${domain}${path}`;
182
+ try {
183
+ const spec = await fetchSpec(url);
184
+ if (spec?.openapi || spec?.swagger) {
185
+ log(`Found spec at ${url}`);
186
+ return spec;
187
+ }
188
+ }
189
+ catch {
190
+ // try next
191
+ }
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+ function parseSpec(data, source) {
197
+ const isYaml = /\.ya?ml$/i.test(source) ||
198
+ (!source.endsWith(".json") && !data.trimStart().startsWith("{"));
199
+ if (isYaml) {
200
+ return js_yaml_1.default.load(data);
132
201
  }
202
+ return JSON.parse(data);
133
203
  }
134
204
  async function fetchSpec(urlOrPath) {
135
205
  // Local file path — read from disk
136
206
  if (!urlOrPath.startsWith("http://") && !urlOrPath.startsWith("https://")) {
137
207
  const fs = require("fs");
138
208
  const data = fs.readFileSync(urlOrPath, "utf-8");
139
- return JSON.parse(data);
209
+ return parseSpec(data, urlOrPath);
140
210
  }
141
211
  // Remote URL — fetch via HTTPS/HTTP
142
212
  const mod = urlOrPath.startsWith("https://") ? await Promise.resolve().then(() => __importStar(require("https"))) : await Promise.resolve().then(() => __importStar(require("http")));
@@ -146,17 +216,21 @@ async function fetchSpec(urlOrPath) {
146
216
  fetchSpec(res.headers.location).then(resolve, reject);
147
217
  return;
148
218
  }
219
+ if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
220
+ reject(new Error(`HTTP ${res.statusCode}`));
221
+ return;
222
+ }
149
223
  let data = "";
150
224
  res.on("data", (chunk) => (data += chunk));
151
225
  res.on("end", () => {
152
226
  try {
153
- resolve(JSON.parse(data));
227
+ resolve(parseSpec(data, urlOrPath));
154
228
  }
155
- catch {
156
- reject(new Error("Invalid JSON"));
229
+ catch (err) {
230
+ reject(new Error(`Failed to parse spec: ${err}`));
157
231
  }
158
232
  });
159
233
  res.on("error", reject);
160
- });
234
+ }).on("error", reject);
161
235
  });
162
236
  }
@@ -1,4 +1,6 @@
1
1
  export interface OpenAPISpec {
2
+ openapi?: string;
3
+ swagger?: string;
2
4
  paths: Record<string, Record<string, any>>;
3
5
  info?: {
4
6
  title?: string;
@@ -10,6 +12,7 @@ export interface OpenAPISpec {
10
12
  components?: {
11
13
  schemas?: Record<string, any>;
12
14
  };
15
+ security?: Array<Record<string, string[]>>;
13
16
  }
14
17
  export interface SpecEntry {
15
18
  status: "loading" | "loaded" | "not-found";
@@ -20,6 +20,7 @@ interface OpenAPIOperation {
20
20
  responses?: Record<string, {
21
21
  content?: Record<string, {
22
22
  schema?: OpenAPISchema;
23
+ example?: unknown;
23
24
  }>;
24
25
  description?: string;
25
26
  }>;
@@ -42,7 +43,14 @@ interface FullOpenAPISpec {
42
43
  paths: Record<string, Record<string, OpenAPIOperation>>;
43
44
  components?: {
44
45
  schemas?: Record<string, OpenAPISchema>;
46
+ securitySchemes?: Record<string, {
47
+ type?: string;
48
+ in?: string;
49
+ name?: string;
50
+ description?: string;
51
+ }>;
45
52
  };
53
+ security?: Array<Record<string, string[]>>;
46
54
  servers?: Array<{
47
55
  url?: string;
48
56
  }>;
@@ -81,15 +81,20 @@ function generateDtsContent(domainSpecs) {
81
81
  if (!operation?.responses)
82
82
  continue;
83
83
  const successResp = operation.responses["200"] ?? operation.responses["201"];
84
- if (!successResp?.content?.["application/json"]?.schema)
84
+ const jsonContent = successResp?.content?.["application/json"];
85
+ if (!jsonContent?.schema && !jsonContent?.example)
85
86
  continue;
86
- const schema = successResp.content["application/json"].schema;
87
+ const schema = jsonContent.schema ?? inferSchemaFromExample(jsonContent.example);
87
88
  const fullUrl = `${baseUrl}${basePath}${path}`;
88
89
  const baseName = sanitizeTypeName(domain, path, method);
89
90
  const count = typeNameCounter.get(baseName) ?? 0;
90
91
  typeNameCounter.set(baseName, count + 1);
91
92
  const typeName = count > 0 ? `${baseName}_${count}` : baseName;
92
93
  const typeBody = schemaToType(schema, resolver, 1);
94
+ const opDesc = operation.summary ?? operation.description;
95
+ if (opDesc) {
96
+ typeDefinitions.push(` /** ${method.toUpperCase()} ${path} — ${opDesc.replace(/\*\//g, "* /")} */`);
97
+ }
93
98
  typeDefinitions.push(` type ${typeName} = ${typeBody};`);
94
99
  // ── Body type ──
95
100
  const reqBodySchema = operation.requestBody?.content?.["application/json"]?.schema ??
@@ -124,26 +129,64 @@ function generateDtsContent(domainSpecs) {
124
129
  : paramSchema?.type === "number" ? "number"
125
130
  : paramSchema?.type === "boolean" ? "boolean"
126
131
  : "string";
127
- queryParams.push({ name: resolved.name, type: tsType, required: !!resolved.required });
132
+ queryParams.push({ name: resolved.name, type: tsType, required: !!resolved.required, description: resolved.description });
128
133
  }
129
134
  }
130
135
  let queryParamsArg = "never";
131
136
  if (queryParams.length > 0) {
132
137
  const queryParamsTypeName = `${typeName}_QueryParams`;
133
- const queryProps = queryParams
134
- .map((q) => `${safePropName(q.name)}${q.required ? "" : "?"}: ${q.type}`)
135
- .join("; ");
136
- typeDefinitions.push(` type ${queryParamsTypeName} = { ${queryProps} };`);
138
+ const queryPropLines = [];
139
+ for (const q of queryParams) {
140
+ if (q.description)
141
+ queryPropLines.push(`/** ${q.description.replace(/\*\//g, "* /")} */`);
142
+ queryPropLines.push(`${safePropName(q.name)}${q.required ? "" : "?"}: ${q.type};`);
143
+ }
144
+ typeDefinitions.push(` type ${queryParamsTypeName} = { ${queryPropLines.join(" ")} };`);
137
145
  queryParamsArg = queryParamsTypeName;
138
146
  }
139
- const optionsType = `Options<${bodyTypeArg}, ${pathParamsArg}, ${queryParamsArg}>`;
147
+ // ── Headers type (from security schemes + header params) ──
148
+ const headerProps = [];
149
+ // Collect from header parameters
150
+ for (const param of allParams) {
151
+ const resolved = param.$ref ? resolveRef(spec, param.$ref) : param;
152
+ if (resolved?.in === "header") {
153
+ headerProps.push({ name: resolved.name, description: resolved.description, required: !!resolved.required });
154
+ }
155
+ }
156
+ // Collect from security schemes
157
+ const opSecurity = operation.security ?? spec.security;
158
+ if (opSecurity && spec.components?.securitySchemes) {
159
+ for (const req of opSecurity) {
160
+ for (const schemeName of Object.keys(req)) {
161
+ const scheme = spec.components.securitySchemes[schemeName];
162
+ if (scheme?.type === "apiKey" && scheme.in === "header" && scheme.name) {
163
+ if (!headerProps.some((h) => h.name === scheme.name)) {
164
+ headerProps.push({ name: scheme.name, description: scheme.description, required: true });
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+ let headersArg = "Record<string, string>";
171
+ if (headerProps.length > 0) {
172
+ const headersTypeName = `${typeName}_Headers`;
173
+ const headerPropLines = [];
174
+ for (const h of headerProps) {
175
+ if (h.description)
176
+ headerPropLines.push(`/** ${h.description.replace(/\*\//g, "* /")} */`);
177
+ headerPropLines.push(`${safePropName(h.name)}${h.required ? "" : "?"}: string;`);
178
+ }
179
+ typeDefinitions.push(` type ${headersTypeName} = { ${headerPropLines.join(" ")} };`);
180
+ headersArg = headersTypeName;
181
+ }
182
+ const optionsType = `Options<${bodyTypeArg}, ${pathParamsArg}, ${queryParamsArg}, ${headersArg}>`;
140
183
  overloads.push(` ${method}(url: \`${escapeTemplateUrl(fullUrl)}\`, options?: ${optionsType}): ResponsePromise<${typeName}>;`);
141
184
  // Don't break — emit an overload per HTTP method
142
185
  }
143
186
  }
144
187
  }
145
188
  lines.push("// Response types");
146
- lines.push(...typeDefinitions.map((l) => l.replace(/^ /, "export ")));
189
+ lines.push(...typeDefinitions.map((l) => l.startsWith(" type ") ? l.replace(/^ /, "export ") : l.replace(/^ /, "")));
147
190
  lines.push("");
148
191
  lines.push("export interface TyFetch {");
149
192
  lines.push(...overloads.map((l) => l.replace(/^ /, " ")));
@@ -157,9 +200,31 @@ function resolveRef(spec, ref) {
157
200
  return undefined;
158
201
  return spec.components?.schemas?.[match[1]];
159
202
  }
203
+ /** Infer an OpenAPI schema from a JSON example value. */
204
+ function inferSchemaFromExample(example) {
205
+ if (example === null || example === undefined)
206
+ return { type: "string" };
207
+ if (typeof example === "string")
208
+ return { type: "string" };
209
+ if (typeof example === "number")
210
+ return { type: "number" };
211
+ if (typeof example === "boolean")
212
+ return { type: "boolean" };
213
+ if (Array.isArray(example)) {
214
+ return { type: "array", items: example.length > 0 ? inferSchemaFromExample(example[0]) : { type: "string" } };
215
+ }
216
+ if (typeof example === "object") {
217
+ const properties = {};
218
+ for (const [key, value] of Object.entries(example)) {
219
+ properties[key] = inferSchemaFromExample(value);
220
+ }
221
+ return { type: "object", properties };
222
+ }
223
+ return { type: "string" };
224
+ }
160
225
  function schemaToType(schema, resolver, depth) {
161
226
  // Depth limit to avoid huge nested types
162
- if (depth > 2)
227
+ if (depth > 4)
163
228
  return "any";
164
229
  const indent = " ".repeat(depth + 1);
165
230
  const outerIndent = " ".repeat(depth);
@@ -195,6 +260,9 @@ function schemaToType(schema, resolver, depth) {
195
260
  for (const [key, propSchema] of Object.entries(props)) {
196
261
  const optional = required.has(key) ? "" : "?";
197
262
  const propType = schemaToType(propSchema, resolver, depth + 1);
263
+ if (propSchema.description) {
264
+ propLines.push(`${indent}/** ${propSchema.description.replace(/\*\//g, "* /")} */`);
265
+ }
198
266
  propLines.push(`${indent}${safePropName(key)}${optional}: ${propType};`);
199
267
  }
200
268
  if (schema.additionalProperties) {
package/index.d.ts CHANGED
@@ -6,12 +6,14 @@ export interface Options<
6
6
  TBody = never,
7
7
  TPathParams = never,
8
8
  TQueryParams = never,
9
- > extends Omit<RequestInit, 'body'> {
9
+ THeaders extends Record<string, string> = Record<string, string>,
10
+ > extends Omit<RequestInit, 'body' | 'headers'> {
10
11
  body?: TBody;
11
12
  params?: {
12
13
  path?: TPathParams;
13
14
  query?: TQueryParams;
14
15
  };
16
+ headers?: THeaders & Record<string, string>;
15
17
  prefixUrl?: string;
16
18
  }
17
19
 
@@ -23,16 +25,19 @@ export interface ResponsePromise<T = unknown> extends PromiseLike<T> {
23
25
  formData(): Promise<FormData>;
24
26
  }
25
27
 
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ type BaseOptions = Options<any, Record<string, any>, Record<string, any>>;
30
+
26
31
  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;
32
+ (url: string, options?: BaseOptions): ResponsePromise<any>;
33
+ get(url: string, options?: BaseOptions): ResponsePromise<any>;
34
+ post(url: string, options?: BaseOptions): ResponsePromise<any>;
35
+ put(url: string, options?: BaseOptions): ResponsePromise<any>;
36
+ patch(url: string, options?: BaseOptions): ResponsePromise<any>;
37
+ delete(url: string, options?: BaseOptions): ResponsePromise<any>;
38
+ head(url: string, options?: BaseOptions): ResponsePromise<any>;
39
+ create(defaults?: BaseOptions): TyFetch;
40
+ extend(defaults?: BaseOptions): TyFetch;
36
41
  HTTPError: typeof HTTPError;
37
42
  }
38
43
 
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "ty-fetch",
3
- "version": "0.0.2-beta.1",
3
+ "version": "0.0.2-beta.11",
4
+ "description": "Automatic TypeScript types for any REST API. No codegen, no manual types — just fetch.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "typescript",
8
+ "fetch",
9
+ "openapi",
10
+ "types",
11
+ "api",
12
+ "swagger",
13
+ "codegen-free"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/alnorris/ty-fetch.git"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
4
22
  "main": "index.js",
5
23
  "types": "index.d.ts",
6
24
  "exports": {
@@ -22,16 +40,23 @@
22
40
  "base.d.ts",
23
41
  "index.js",
24
42
  "index.d.ts",
25
- "plugin.js"
43
+ "plugin.js",
44
+ "README.md",
45
+ "LICENSE"
26
46
  ],
27
47
  "scripts": {
28
- "test": "node --test test/*.test.mjs",
48
+ "test": "tsx --test test/*.test.ts",
29
49
  "build": "tsc -p tsconfig.build.json",
30
50
  "watch": "tsc -p tsconfig.build.json --watch",
31
51
  "prepublishOnly": "cp base.d.ts index.d.ts && npm run build"
32
52
  },
33
53
  "devDependencies": {
54
+ "@types/js-yaml": "^4.0.9",
34
55
  "@types/node": "^25.5.2",
56
+ "tsx": "^4.21.0",
35
57
  "typescript": "^5.5.0"
58
+ },
59
+ "dependencies": {
60
+ "js-yaml": "^4.1.1"
36
61
  }
37
62
  }