ty-fetch 0.1.0 → 0.1.2

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 CHANGED
@@ -7,8 +7,9 @@
7
7
  [![npm version](https://img.shields.io/npm/v/ty-fetch.svg)](https://www.npmjs.com/package/ty-fetch)
8
8
  [![license](https://img.shields.io/npm/l/ty-fetch.svg)](https://github.com/alnorris/ty-fetch/blob/main/LICENSE)
9
9
  [![CI](https://github.com/alnorris/ty-fetch/actions/workflows/ci.yml/badge.svg)](https://github.com/alnorris/ty-fetch/actions/workflows/ci.yml)
10
+ [![Try it](https://img.shields.io/badge/try%20it-playground-blue)](https://codespaces.new/alnorris/ty-fetch?quickstart=1)
10
11
 
11
- **A tiny, zero-dependency HTTP client that auto-discovers your OpenAPI specs and types your API calls on-the-fly. No codegen, no build step — types generated by a TS plugin.**
12
+ **A tiny, zero-dependency HTTP client that auto-discovers your OpenAPI specs and types your API calls on-the-fly. No codegen, no build step — types generated by a TS plugin.** [Try it in your browser →](https://codespaces.new/alnorris/ty-fetch?quickstart=1)
12
13
 
13
14
  ```bash
14
15
  npm install ty-fetch
@@ -64,16 +65,16 @@ Types appear in your editor instantly. When the spec changes, types update autom
64
65
 
65
66
  ### Compared to other tools
66
67
 
67
- | | 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) |
68
- |---|---|---|---|---|
69
- | **Codegen step** | None | `npx openapi-typescript ...` | Needs openapi-typescript first | `npx orval` |
70
- | **Build step** | None | Required | Required | Required |
71
- | **Generated files** | None | `.d.ts` files | `.d.ts` files | Full client |
72
- | **Spec changes** | Auto-updates | Re-run codegen | Re-run codegen | Re-run codegen |
73
- | **Auto-discovery** | Probes well-known paths | Manual | Manual | Manual |
74
- | **Editor integration** | TS plugin (autocomplete, hover, diagnostics) | Types only | Types only | Types only |
75
- | **Path validation** | Typo detection with suggestions | None | None | None |
76
- | **Runtime** | Thin fetch wrapper (~100 LOC) | None (types only) | Fetch wrapper | Axios/fetch client |
68
+ | | ty-fetch | [openapi-fetch](https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch) | [orval](https://github.com/orval-labs/orval) |
69
+ |---|---|---|---|
70
+ | **Codegen step** | None | `npx openapi-typescript` first | `npx orval` |
71
+ | **Generated files** | None | `.d.ts` type files | Full client code |
72
+ | **Spec changes** | Auto-updates | Re-run codegen | Re-run codegen |
73
+ | **Auto-discovery** | Probes well-known paths | Manual | Manual |
74
+ | **Editor integration** | TS plugin (autocomplete, hover, path validation) | Types only | Types only |
75
+ | **Path validation** | Typo detection with "did you mean?" | None | None |
76
+ | **Streaming** | SSE, NDJSON built-in | `parseAs: "stream"` | Depends on client |
77
+ | **Runtime** | ~100 LOC, zero deps | ~6 KB | Axios/fetch client |
77
78
 
78
79
  ---
79
80
 
@@ -108,7 +109,7 @@ That's it. ✨
108
109
 
109
110
  > **VS Code users:** Make sure you're using the workspace TypeScript version, not the built-in one. Command Palette → **TypeScript: Select TypeScript Version** → **Use Workspace Version**
110
111
 
111
- Want to try it without setting up a project? Check out the [playground](./playground/).
112
+ Want to try it without setting up a project? [Open in GitHub Codespaces →](https://codespaces.new/alnorris/ty-fetch?quickstart=1) — types work instantly, no local setup needed.
112
113
 
113
114
  ---
114
115
 
package/base.d.ts CHANGED
@@ -3,9 +3,9 @@ export class HTTPError extends Error {
3
3
  }
4
4
 
5
5
  export interface Options<
6
- TBody = never,
7
- TPathParams = never,
8
- TQueryParams = never,
6
+ TBody = unknown,
7
+ TPathParams = Record<string, string>,
8
+ TQueryParams = Record<string, string | number | boolean>,
9
9
  THeaders extends Record<string, string> = Record<string, string>,
10
10
  > extends Omit<RequestInit, 'body' | 'headers'> {
11
11
  body?: TBody;
@@ -31,19 +31,19 @@ export interface StreamResult<T = unknown> extends AsyncIterable<T> {
31
31
  }
32
32
 
33
33
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
- type BaseOptions = Options<any, Record<string, any>, Record<string, any>>;
34
+ type Untyped = any;
35
35
 
36
36
  export interface TyFetch {
37
- (url: string, options?: BaseOptions): Promise<FetchResult<any>>;
38
- get(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
39
- post(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
40
- put(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
41
- patch(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
42
- delete(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
43
- head(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
44
- stream(url: string, options?: BaseOptions): StreamResult;
45
- create(defaults?: BaseOptions): TyFetch;
46
- extend(defaults?: BaseOptions): TyFetch;
37
+ <T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
38
+ get<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
39
+ post<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
40
+ put<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
41
+ patch<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
42
+ delete<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
43
+ head<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
44
+ stream<T = Untyped>(url: string, options?: Options): StreamResult<T>;
45
+ create(defaults?: Options): TyFetch;
46
+ extend(defaults?: Options): TyFetch;
47
47
  use(middleware: Middleware): TyFetch;
48
48
  HTTPError: typeof HTTPError;
49
49
  }
@@ -1,5 +1,9 @@
1
1
  import type { OpenAPISpec } from "./types";
2
+ /** Check if an actual URL path matches an OpenAPI path template with {param} segments. */
2
3
  export declare function matchesPathTemplate(actualPath: string, templatePath: string): boolean;
4
+ /** Return true if the given path exists in the spec, either exactly or via template matching. */
3
5
  export declare function pathExistsInSpec(path: string, spec: OpenAPISpec): boolean;
6
+ /** Find the matching spec path key for a given API path, returning null if not found. */
4
7
  export declare function findSpecPath(apiPath: string, spec: OpenAPISpec): string | null;
8
+ /** Find the closest path to the target using Levenshtein distance, for typo suggestions. */
5
9
  export declare function findClosestPath(target: string, paths: string[]): string | null;
@@ -4,6 +4,7 @@ exports.matchesPathTemplate = matchesPathTemplate;
4
4
  exports.pathExistsInSpec = pathExistsInSpec;
5
5
  exports.findSpecPath = findSpecPath;
6
6
  exports.findClosestPath = findClosestPath;
7
+ /** Check if an actual URL path matches an OpenAPI path template with {param} segments. */
7
8
  function matchesPathTemplate(actualPath, templatePath) {
8
9
  const actualParts = actualPath.split("/");
9
10
  const templateParts = templatePath.split("/");
@@ -11,16 +12,19 @@ function matchesPathTemplate(actualPath, templatePath) {
11
12
  return false;
12
13
  return templateParts.every((tp, i) => tp.startsWith("{") || tp === actualParts[i]);
13
14
  }
15
+ /** Return true if the given path exists in the spec, either exactly or via template matching. */
14
16
  function pathExistsInSpec(path, spec) {
15
17
  if (spec.paths[path])
16
18
  return true;
17
19
  return Object.keys(spec.paths).some((tp) => matchesPathTemplate(path, tp));
18
20
  }
21
+ /** Find the matching spec path key for a given API path, returning null if not found. */
19
22
  function findSpecPath(apiPath, spec) {
20
23
  if (spec.paths[apiPath])
21
24
  return apiPath;
22
25
  return Object.keys(spec.paths).find((tp) => matchesPathTemplate(apiPath, tp)) ?? null;
23
26
  }
27
+ /** Find the closest path to the target using Levenshtein distance, for typo suggestions. */
24
28
  function findClosestPath(target, paths) {
25
29
  let best = null;
26
30
  let bestDist = Infinity;
@@ -1,5 +1,9 @@
1
1
  import type { OpenAPISpec } from "./types";
2
+ /** Resolve a $ref pointer to its target schema, or return the schema as-is if not a ref. */
2
3
  export declare function resolveSchemaRef(schema: any, spec: OpenAPISpec): any;
4
+ /** Extract the JSON or form-encoded request body schema from an operation. */
3
5
  export declare function getRequestBodySchema(operation: any): any | null;
6
+ /** Extract the JSON response schema from a 200 or 201 response. */
4
7
  export declare function getResponseSchema(operation: any): any | null;
8
+ /** Return true if the operation's request body is marked as required. */
5
9
  export declare function isRequestBodyRequired(operation: any): boolean;
@@ -4,6 +4,7 @@ exports.resolveSchemaRef = resolveSchemaRef;
4
4
  exports.getRequestBodySchema = getRequestBodySchema;
5
5
  exports.getResponseSchema = getResponseSchema;
6
6
  exports.isRequestBodyRequired = isRequestBodyRequired;
7
+ /** Resolve a $ref pointer to its target schema, or return the schema as-is if not a ref. */
7
8
  function resolveSchemaRef(schema, spec) {
8
9
  if (!schema)
9
10
  return null;
@@ -15,15 +16,18 @@ function resolveSchemaRef(schema, spec) {
15
16
  }
16
17
  return schema;
17
18
  }
19
+ /** Extract the JSON or form-encoded request body schema from an operation. */
18
20
  function getRequestBodySchema(operation) {
19
21
  return (operation?.requestBody?.content?.["application/json"]?.schema ??
20
22
  operation?.requestBody?.content?.["application/x-www-form-urlencoded"]?.schema ??
21
23
  null);
22
24
  }
25
+ /** Extract the JSON response schema from a 200 or 201 response. */
23
26
  function getResponseSchema(operation) {
24
27
  const resp = operation?.responses?.["200"] ?? operation?.responses?.["201"];
25
28
  return resp?.content?.["application/json"]?.schema ?? null;
26
29
  }
30
+ /** Return true if the operation's request body is marked as required. */
27
31
  function isRequestBodyRequired(operation) {
28
32
  return !!operation?.requestBody?.required;
29
33
  }
@@ -1,10 +1,10 @@
1
1
  import type { OpenAPISpec, SpecEntry } from "./types";
2
- export declare const KNOWN_SPECS: Record<string, string>;
2
+ export declare const configuredSpecs: Record<string, string>;
3
3
  export declare const specCache: Map<string, SpecEntry>;
4
4
  /**
5
5
  * Register user-provided spec mappings (from tsconfig plugin config).
6
6
  * Values can be URLs (https://...) or file paths (resolved relative to basePath).
7
- * These override built-in KNOWN_SPECS for the same domain.
7
+ * These override built-in configuredSpecs for the same domain.
8
8
  */
9
9
  export declare function registerSpecs(specs: Record<string, string>, basePath: string): void;
10
10
  /**
@@ -16,5 +16,7 @@ export declare function ensureSpec(domain: string, log: (msg: string) => void, o
16
16
  /**
17
17
  * Synchronous version — fetches and blocks. Used by CLI.
18
18
  */
19
+ /** Return a cached spec entry if available, without triggering a new fetch. */
19
20
  export declare function ensureSpecSync(domain: string, _log: (msg: string) => void): Promise<SpecEntry>;
21
+ /** Fetch and cache the OpenAPI spec for a domain, awaiting the result. */
20
22
  export declare function fetchSpecForDomain(domain: string, log: (msg: string) => void): Promise<SpecEntry>;
@@ -36,28 +36,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.specCache = exports.KNOWN_SPECS = void 0;
39
+ exports.specCache = exports.configuredSpecs = void 0;
40
40
  exports.registerSpecs = registerSpecs;
41
41
  exports.ensureSpec = ensureSpec;
42
42
  exports.ensureSpecSync = ensureSpecSync;
43
43
  exports.fetchSpecForDomain = fetchSpecForDomain;
44
44
  const js_yaml_1 = __importDefault(require("js-yaml"));
45
- exports.KNOWN_SPECS = {};
45
+ exports.configuredSpecs = {};
46
46
  exports.specCache = new Map();
47
47
  /**
48
48
  * Register user-provided spec mappings (from tsconfig plugin config).
49
49
  * Values can be URLs (https://...) or file paths (resolved relative to basePath).
50
- * These override built-in KNOWN_SPECS for the same domain.
50
+ * These override built-in configuredSpecs for the same domain.
51
51
  */
52
52
  function registerSpecs(specs, basePath) {
53
53
  const pathMod = require("node:path");
54
54
  for (const [domain, value] of Object.entries(specs)) {
55
55
  if (value.startsWith("http://") || value.startsWith("https://")) {
56
- exports.KNOWN_SPECS[domain] = value;
56
+ exports.configuredSpecs[domain] = value;
57
57
  }
58
58
  else {
59
59
  // Resolve relative file paths against basePath
60
- exports.KNOWN_SPECS[domain] = pathMod.resolve(basePath, value);
60
+ exports.configuredSpecs[domain] = pathMod.resolve(basePath, value);
61
61
  }
62
62
  // Clear any cached entry so the new source is used
63
63
  exports.specCache.delete(domain);
@@ -72,7 +72,7 @@ function ensureSpec(domain, log, onLoaded) {
72
72
  const existing = exports.specCache.get(domain);
73
73
  if (existing)
74
74
  return existing;
75
- const specUrl = exports.KNOWN_SPECS[domain];
75
+ const specUrl = exports.configuredSpecs[domain];
76
76
  const entry = { status: "loading", spec: null, fetchedAt: Date.now() };
77
77
  exports.specCache.set(domain, entry);
78
78
  if (specUrl) {
@@ -115,6 +115,7 @@ function ensureSpec(domain, log, onLoaded) {
115
115
  /**
116
116
  * Synchronous version — fetches and blocks. Used by CLI.
117
117
  */
118
+ /** Return a cached spec entry if available, without triggering a new fetch. */
118
119
  async function ensureSpecSync(domain, _log) {
119
120
  const existing = exports.specCache.get(domain);
120
121
  if (existing?.status !== "loading")
@@ -122,11 +123,12 @@ async function ensureSpecSync(domain, _log) {
122
123
  // Already loading — wait for it (shouldn't happen in CLI flow)
123
124
  return existing;
124
125
  }
126
+ /** Fetch and cache the OpenAPI spec for a domain, awaiting the result. */
125
127
  async function fetchSpecForDomain(domain, log) {
126
128
  const existing = exports.specCache.get(domain);
127
129
  if (existing && existing.status === "loaded")
128
130
  return existing;
129
- const specUrl = exports.KNOWN_SPECS[domain];
131
+ const specUrl = exports.configuredSpecs[domain];
130
132
  if (specUrl) {
131
133
  log(`Fetching spec for ${domain} from ${specUrl}`);
132
134
  try {
@@ -171,6 +173,9 @@ const WELL_KNOWN_PATHS = [
171
173
  "/docs/openapi.yaml",
172
174
  "/swagger.json",
173
175
  "/api-docs/openapi.json",
176
+ "/api/v1/openapi.json",
177
+ "/api/v2/openapi.json",
178
+ "/api/v3/openapi.json",
174
179
  ];
175
180
  async function probeWellKnownSpecs(domain, log) {
176
181
  // Try HTTPS first, then HTTP for local/dev servers
@@ -1,4 +1,7 @@
1
1
  import type { OpenAPISpec, ParsedUrl } from "./types";
2
+ /** Parse an HTTP(S) URL string into its domain and path components. */
2
3
  export declare function parseFetchUrl(text: string): ParsedUrl | null;
4
+ /** Extract the base path prefix from an OpenAPI spec (Swagger 2.0 basePath or OpenAPI 3.x servers). */
3
5
  export declare function getBasePath(spec: OpenAPISpec): string;
6
+ /** Remove the spec's base path prefix from a URL path to get the API-relative path. */
4
7
  export declare function stripBasePath(urlPath: string, spec: OpenAPISpec): string;
@@ -3,12 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseFetchUrl = parseFetchUrl;
4
4
  exports.getBasePath = getBasePath;
5
5
  exports.stripBasePath = stripBasePath;
6
+ /** Parse an HTTP(S) URL string into its domain and path components. */
6
7
  function parseFetchUrl(text) {
7
8
  const match = text.match(/^https?:\/\/([^/]+)(\/[^?#]*)?/);
8
9
  if (!match)
9
10
  return null;
10
11
  return { domain: match[1], path: match[2] || "/" };
11
12
  }
13
+ /** Extract the base path prefix from an OpenAPI spec (Swagger 2.0 basePath or OpenAPI 3.x servers). */
12
14
  function getBasePath(spec) {
13
15
  // Swagger 2.0: basePath is a top-level field
14
16
  if (spec.basePath) {
@@ -29,6 +31,7 @@ function getBasePath(spec) {
29
31
  return "";
30
32
  }
31
33
  }
34
+ /** Remove the spec's base path prefix from a URL path to get the API-relative path. */
32
35
  function stripBasePath(urlPath, spec) {
33
36
  const base = getBasePath(spec);
34
37
  if (base && urlPath.startsWith(base)) {
@@ -65,17 +65,11 @@ export interface DomainSpec {
65
65
  basePath: string;
66
66
  spec: FullOpenAPISpec;
67
67
  }
68
- /**
69
- * Generate one .d.ts per domain — keeps each file under TS overload limits.
70
- * Returns a map of filename → content.
71
- */
72
- /**
73
- * Filter spec paths to only those matching URLs seen in the codebase.
74
- * usedUrls is a Set of parsed { domain, path } objects.
75
- */
68
+ /** Generate one .d.ts per domain, filtered to only paths matching URLs seen in the codebase. */
76
69
  export declare function generatePerDomain(domainSpecs: DomainSpec[], usedUrls: Array<{
77
70
  domain: string;
78
71
  path: string;
79
72
  }>): Map<string, string>;
73
+ /** Generate the full .d.ts file content with typed overloads for the given domain specs. */
80
74
  export declare function generateDtsContent(domainSpecs: DomainSpec[]): string;
81
75
  export {};
@@ -6,14 +6,7 @@
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.generatePerDomain = generatePerDomain;
8
8
  exports.generateDtsContent = generateDtsContent;
9
- /**
10
- * Generate one .d.ts per domain — keeps each file under TS overload limits.
11
- * Returns a map of filename → content.
12
- */
13
- /**
14
- * Filter spec paths to only those matching URLs seen in the codebase.
15
- * usedUrls is a Set of parsed { domain, path } objects.
16
- */
9
+ /** Generate one .d.ts per domain, filtered to only paths matching URLs seen in the codebase. */
17
10
  function generatePerDomain(domainSpecs, usedUrls) {
18
11
  const result = new Map();
19
12
  // Group used paths by domain
@@ -54,6 +47,7 @@ function generatePerDomain(domainSpecs, usedUrls) {
54
47
  }
55
48
  return result;
56
49
  }
50
+ /** Generate the full .d.ts file content with typed overloads for the given domain specs. */
57
51
  function generateDtsContent(domainSpecs) {
58
52
  const lines = [
59
53
  "// Auto-generated by ty-fetch plugin. Do not edit.",
package/index.d.ts CHANGED
@@ -3,9 +3,9 @@ export class HTTPError extends Error {
3
3
  }
4
4
 
5
5
  export interface Options<
6
- TBody = never,
7
- TPathParams = never,
8
- TQueryParams = never,
6
+ TBody = unknown,
7
+ TPathParams = Record<string, string>,
8
+ TQueryParams = Record<string, string | number | boolean>,
9
9
  THeaders extends Record<string, string> = Record<string, string>,
10
10
  > extends Omit<RequestInit, 'body' | 'headers'> {
11
11
  body?: TBody;
@@ -31,19 +31,19 @@ export interface StreamResult<T = unknown> extends AsyncIterable<T> {
31
31
  }
32
32
 
33
33
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
- type BaseOptions = Options<any, Record<string, any>, Record<string, any>>;
34
+ type Untyped = any;
35
35
 
36
36
  export interface TyFetch {
37
- (url: string, options?: BaseOptions): Promise<FetchResult<any>>;
38
- get(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
39
- post(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
40
- put(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
41
- patch(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
42
- delete(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
43
- head(url: string, options?: BaseOptions): Promise<FetchResult<any>>;
44
- stream(url: string, options?: BaseOptions): StreamResult;
45
- create(defaults?: BaseOptions): TyFetch;
46
- extend(defaults?: BaseOptions): TyFetch;
37
+ <T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
38
+ get<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
39
+ post<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
40
+ put<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
41
+ patch<T = Untyped>(url: string, options?: Options): Promise<FetchResult<T>>;
42
+ delete<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
43
+ head<T = Untyped>(url: string, options?: Options<never>): Promise<FetchResult<T>>;
44
+ stream<T = Untyped>(url: string, options?: Options): StreamResult<T>;
45
+ create(defaults?: Options): TyFetch;
46
+ extend(defaults?: Options): TyFetch;
47
47
  use(middleware: Middleware): TyFetch;
48
48
  HTTPError: typeof HTTPError;
49
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ty-fetch",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Automatic TypeScript types for any REST API. No codegen, no manual types — just fetch.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -50,14 +50,12 @@
50
50
  "build": "tsc -p tsconfig.build.json",
51
51
  "watch": "tsc -p tsconfig.build.json --watch",
52
52
  "lint": "eslint src/ test/",
53
- "check": "biome check src/ test/ && eslint src/ test/",
54
- "format": "biome check --fix src/ test/",
53
+ "check": "eslint src/ test/",
55
54
  "prepare": "npm run build",
56
55
  "prepublishOnly": "cp base.d.ts index.d.ts && npm run build",
57
56
  "release": "np"
58
57
  },
59
58
  "devDependencies": {
60
- "@biomejs/biome": "^2.4.10",
61
59
  "@eslint/js": "^10.0.1",
62
60
  "@types/js-yaml": "^4.0.9",
63
61
  "@types/node": "^25.5.2",