trpc-gen-python 0.1.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 +84 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +31 -0
- package/dist/codegen.d.ts +5 -0
- package/dist/codegen.js +340 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +31 -0
- package/dist/infer-outputs.d.ts +10 -0
- package/dist/infer-outputs.js +165 -0
- package/dist/introspect.d.ts +2 -0
- package/dist/introspect.js +146 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.js +1 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# trpc-gen-python
|
|
2
|
+
|
|
3
|
+
Generate type-safe Python clients from tRPC routers. Automatically converts your tRPC endpoints into Pydantic models and httpx-based client code.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx trpc-gen-python generate
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install globally:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g trpc-gen-python
|
|
15
|
+
trpc-gen-python generate
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
1. Create a `trpc-gen-python.toml` configuration file in your project root:
|
|
21
|
+
|
|
22
|
+
```toml
|
|
23
|
+
# Which endpoints to generate (supports glob patterns)
|
|
24
|
+
endpoints = ["user.*", "post.list"]
|
|
25
|
+
|
|
26
|
+
[router]
|
|
27
|
+
# Path to your tRPC router file
|
|
28
|
+
path = "./src/server/router.ts"
|
|
29
|
+
# Export name of your router
|
|
30
|
+
export = "appRouter"
|
|
31
|
+
# Base URL for your tRPC API
|
|
32
|
+
base_url = "http://localhost:3000/api/trpc"
|
|
33
|
+
|
|
34
|
+
[output]
|
|
35
|
+
# Output directory for generated Python code
|
|
36
|
+
dir = "./generated"
|
|
37
|
+
# Name of the generated Python package
|
|
38
|
+
package_name = "trpc_client"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
2. Run the generator:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx trpc-gen-python generate
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
3. Use the generated Python client:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from generated.trpc_client import TRPCClient
|
|
51
|
+
|
|
52
|
+
client = TRPCClient()
|
|
53
|
+
|
|
54
|
+
# Call your tRPC procedures
|
|
55
|
+
user = client.user.get(id=123)
|
|
56
|
+
posts = client.post.list(limit=10)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## CLI Options
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
trpc-gen-python generate [options]
|
|
63
|
+
|
|
64
|
+
Options:
|
|
65
|
+
-c, --config <path> Path to config file (default: trpc-gen-python.toml)
|
|
66
|
+
-h, --help Display help
|
|
67
|
+
-V, --version Display version
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
## Python Dependencies
|
|
72
|
+
|
|
73
|
+
The generated client requires:
|
|
74
|
+
```bash
|
|
75
|
+
pip install pydantic httpx
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
81
|
+
|
|
82
|
+
## Contributing
|
|
83
|
+
|
|
84
|
+
Issues and pull requests welcome!
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { introspectRouter } from "./introspect.js";
|
|
5
|
+
import { generatePythonClient } from "./codegen.js";
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name("trpc-gen-python")
|
|
9
|
+
.description("Generate Python clients from tRPC routers")
|
|
10
|
+
.version("0.1.0");
|
|
11
|
+
program
|
|
12
|
+
.command("generate")
|
|
13
|
+
.description("Generate Python Pydantic models and httpx client from a tRPC router")
|
|
14
|
+
.option("-c, --config <path>", "Path to config file (default: trpc-gen-python.toml)")
|
|
15
|
+
.action(async (opts) => {
|
|
16
|
+
try {
|
|
17
|
+
console.log("Loading config...");
|
|
18
|
+
const config = loadConfig(opts.config);
|
|
19
|
+
console.log(`Introspecting router at ${config.router.path}...`);
|
|
20
|
+
const procedures = await introspectRouter(config);
|
|
21
|
+
console.log(`Found ${procedures.length} procedure(s), generating Python client...`);
|
|
22
|
+
generatePythonClient(config, procedures);
|
|
23
|
+
console.log("Done!");
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
27
|
+
console.error(`Error: ${message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
program.parse();
|
package/dist/codegen.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
2
|
+
import { resolve, join } from "path";
|
|
3
|
+
/**
|
|
4
|
+
* Convert a tRPC procedure name like "user.getById" to a PascalCase class name
|
|
5
|
+
* like "UserGetById"
|
|
6
|
+
*/
|
|
7
|
+
function toPascalCase(procedureName) {
|
|
8
|
+
return procedureName
|
|
9
|
+
.split(".")
|
|
10
|
+
.map((segment) => segment
|
|
11
|
+
.split(/[-_]/)
|
|
12
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
13
|
+
.join(""))
|
|
14
|
+
.join("");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Convert a tRPC procedure name like "user.getById" to a snake_case method name
|
|
18
|
+
* like "user_get_by_id"
|
|
19
|
+
*/
|
|
20
|
+
function toSnakeCase(procedureName) {
|
|
21
|
+
return procedureName
|
|
22
|
+
.split(".")
|
|
23
|
+
.map((segment) => segment
|
|
24
|
+
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
25
|
+
.replace(/[-]/g, "_")
|
|
26
|
+
.toLowerCase())
|
|
27
|
+
.join("_");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Map a JSON Schema type to a Python type string.
|
|
31
|
+
*/
|
|
32
|
+
function jsonSchemaTypeToPython(schema, modelPrefix, models) {
|
|
33
|
+
if (schema.anyOf) {
|
|
34
|
+
const types = schema.anyOf.map((s) => jsonSchemaTypeToPython(s, modelPrefix, models));
|
|
35
|
+
// If one of the types is None, it's Optional
|
|
36
|
+
const nonNone = types.filter((t) => t !== "None");
|
|
37
|
+
const hasNone = types.some((t) => t === "None");
|
|
38
|
+
if (hasNone && nonNone.length === 1) {
|
|
39
|
+
return `Optional[${nonNone[0]}]`;
|
|
40
|
+
}
|
|
41
|
+
return `Union[${types.join(", ")}]`;
|
|
42
|
+
}
|
|
43
|
+
if (schema.enum) {
|
|
44
|
+
// Generate a string literal union — for simplicity, use str
|
|
45
|
+
return "str";
|
|
46
|
+
}
|
|
47
|
+
const type = schema.type;
|
|
48
|
+
if (type === "string")
|
|
49
|
+
return "str";
|
|
50
|
+
if (type === "number")
|
|
51
|
+
return "float";
|
|
52
|
+
if (type === "integer")
|
|
53
|
+
return "int";
|
|
54
|
+
if (type === "boolean")
|
|
55
|
+
return "bool";
|
|
56
|
+
if (type === "null")
|
|
57
|
+
return "None";
|
|
58
|
+
if (type === "array") {
|
|
59
|
+
if (schema.items) {
|
|
60
|
+
const itemType = jsonSchemaTypeToPython(schema.items, modelPrefix, models);
|
|
61
|
+
return `List[${itemType}]`;
|
|
62
|
+
}
|
|
63
|
+
return "List[Any]";
|
|
64
|
+
}
|
|
65
|
+
if (type === "object") {
|
|
66
|
+
if (schema.properties) {
|
|
67
|
+
// Generate a nested model
|
|
68
|
+
const nestedName = modelPrefix;
|
|
69
|
+
const nestedCode = generateModelClass(nestedName, schema, models);
|
|
70
|
+
models.push({ name: nestedName, code: nestedCode });
|
|
71
|
+
return nestedName;
|
|
72
|
+
}
|
|
73
|
+
return "Dict[str, Any]";
|
|
74
|
+
}
|
|
75
|
+
return "Any";
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Generate a Pydantic model class from a JSON Schema object.
|
|
79
|
+
*/
|
|
80
|
+
function generateModelClass(className, schema, models) {
|
|
81
|
+
const lines = [];
|
|
82
|
+
lines.push(`class ${className}(BaseModel):`);
|
|
83
|
+
const properties = schema.properties;
|
|
84
|
+
if (!properties || Object.keys(properties).length === 0) {
|
|
85
|
+
lines.push(" pass");
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
const required = new Set(schema.required || []);
|
|
89
|
+
for (const [propName, propSchema] of Object.entries(properties)) {
|
|
90
|
+
const nestedPrefix = `${className}${propName.charAt(0).toUpperCase() + propName.slice(1)}`;
|
|
91
|
+
let pyType = jsonSchemaTypeToPython(propSchema, nestedPrefix, models);
|
|
92
|
+
const isRequired = required.has(propName);
|
|
93
|
+
if (!isRequired && !pyType.startsWith("Optional[")) {
|
|
94
|
+
pyType = `Optional[${pyType}]`;
|
|
95
|
+
}
|
|
96
|
+
const safeName = pythonSafeName(propName);
|
|
97
|
+
const alias = safeName !== propName ? `, alias="${propName}"` : "";
|
|
98
|
+
if (!isRequired) {
|
|
99
|
+
lines.push(` ${safeName}: ${pyType} = Field(None${alias})`);
|
|
100
|
+
}
|
|
101
|
+
else if (alias) {
|
|
102
|
+
lines.push(` ${safeName}: ${pyType} = Field(...${alias})`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
lines.push(` ${safeName}: ${pyType}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Ensure a property name is a valid Python identifier.
|
|
112
|
+
*/
|
|
113
|
+
function pythonSafeName(name) {
|
|
114
|
+
// Replace hyphens/dots with underscores
|
|
115
|
+
let safe = name.replace(/[-.\s]/g, "_");
|
|
116
|
+
// If it starts with a digit, prefix with underscore
|
|
117
|
+
if (/^\d/.test(safe)) {
|
|
118
|
+
safe = "_" + safe;
|
|
119
|
+
}
|
|
120
|
+
// Reserved words
|
|
121
|
+
const reserved = new Set([
|
|
122
|
+
"class",
|
|
123
|
+
"def",
|
|
124
|
+
"return",
|
|
125
|
+
"import",
|
|
126
|
+
"from",
|
|
127
|
+
"if",
|
|
128
|
+
"else",
|
|
129
|
+
"for",
|
|
130
|
+
"while",
|
|
131
|
+
"type",
|
|
132
|
+
"input",
|
|
133
|
+
"list",
|
|
134
|
+
"dict",
|
|
135
|
+
"set",
|
|
136
|
+
"str",
|
|
137
|
+
"int",
|
|
138
|
+
"float",
|
|
139
|
+
"bool",
|
|
140
|
+
"None",
|
|
141
|
+
"True",
|
|
142
|
+
"False",
|
|
143
|
+
"and",
|
|
144
|
+
"or",
|
|
145
|
+
"not",
|
|
146
|
+
"in",
|
|
147
|
+
"is",
|
|
148
|
+
"lambda",
|
|
149
|
+
"with",
|
|
150
|
+
"as",
|
|
151
|
+
"try",
|
|
152
|
+
"except",
|
|
153
|
+
"finally",
|
|
154
|
+
"raise",
|
|
155
|
+
"pass",
|
|
156
|
+
"break",
|
|
157
|
+
"continue",
|
|
158
|
+
"global",
|
|
159
|
+
"nonlocal",
|
|
160
|
+
"del",
|
|
161
|
+
"yield",
|
|
162
|
+
"assert",
|
|
163
|
+
"async",
|
|
164
|
+
"await",
|
|
165
|
+
]);
|
|
166
|
+
if (reserved.has(safe)) {
|
|
167
|
+
safe = safe + "_";
|
|
168
|
+
}
|
|
169
|
+
return safe;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Check if a JSON Schema represents an object type (i.e. should become a Pydantic model).
|
|
173
|
+
*/
|
|
174
|
+
function isObjectSchema(schema) {
|
|
175
|
+
const core = extractCoreSchema(schema);
|
|
176
|
+
return core.type === "object" && !!core.properties;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Get the Python type for a non-object (primitive/array) JSON Schema.
|
|
180
|
+
*/
|
|
181
|
+
function primitiveSchemaToPythonType(schema, models) {
|
|
182
|
+
const core = extractCoreSchema(schema);
|
|
183
|
+
return jsonSchemaTypeToPython(core, "Anon", models);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Generate the models.py content, and return type info for each procedure.
|
|
187
|
+
*/
|
|
188
|
+
function generateModelsFile(procedures) {
|
|
189
|
+
const lines = [
|
|
190
|
+
"from __future__ import annotations",
|
|
191
|
+
"",
|
|
192
|
+
"from pydantic import BaseModel, Field",
|
|
193
|
+
"from typing import Any, Dict, List, Optional, Union",
|
|
194
|
+
"",
|
|
195
|
+
"",
|
|
196
|
+
];
|
|
197
|
+
const allModels = [];
|
|
198
|
+
const typeInfo = [];
|
|
199
|
+
for (const proc of procedures) {
|
|
200
|
+
const pascal = toPascalCase(proc.name);
|
|
201
|
+
const info = {
|
|
202
|
+
name: proc.name,
|
|
203
|
+
inputIsModel: false,
|
|
204
|
+
inputPyType: "",
|
|
205
|
+
outputIsModel: false,
|
|
206
|
+
outputPyType: "dict",
|
|
207
|
+
};
|
|
208
|
+
if (proc.inputSchema) {
|
|
209
|
+
if (isObjectSchema(proc.inputSchema)) {
|
|
210
|
+
const inputName = `${pascal}Input`;
|
|
211
|
+
const coreSchema = extractCoreSchema(proc.inputSchema);
|
|
212
|
+
const code = generateModelClass(inputName, coreSchema, allModels);
|
|
213
|
+
allModels.push({ name: inputName, code });
|
|
214
|
+
info.inputIsModel = true;
|
|
215
|
+
info.inputPyType = inputName;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
info.inputIsModel = false;
|
|
219
|
+
info.inputPyType = primitiveSchemaToPythonType(proc.inputSchema, allModels);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (proc.outputSchema) {
|
|
223
|
+
if (isObjectSchema(proc.outputSchema)) {
|
|
224
|
+
const outputName = `${pascal}Output`;
|
|
225
|
+
const coreSchema = extractCoreSchema(proc.outputSchema);
|
|
226
|
+
const code = generateModelClass(outputName, coreSchema, allModels);
|
|
227
|
+
allModels.push({ name: outputName, code });
|
|
228
|
+
info.outputIsModel = true;
|
|
229
|
+
info.outputPyType = outputName;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
info.outputIsModel = false;
|
|
233
|
+
info.outputPyType = primitiveSchemaToPythonType(proc.outputSchema, allModels);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
typeInfo.push(info);
|
|
237
|
+
}
|
|
238
|
+
for (const model of allModels) {
|
|
239
|
+
lines.push(model.code);
|
|
240
|
+
lines.push("");
|
|
241
|
+
lines.push("");
|
|
242
|
+
}
|
|
243
|
+
return { content: lines.join("\n").trimEnd() + "\n", typeInfo };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Extract the core schema from a zod-to-json-schema output,
|
|
247
|
+
* which may wrap the actual schema in definitions/$ref.
|
|
248
|
+
*/
|
|
249
|
+
function extractCoreSchema(schema) {
|
|
250
|
+
if (schema.$ref && schema.definitions) {
|
|
251
|
+
const refName = schema.$ref.replace("#/definitions/", "");
|
|
252
|
+
return schema.definitions[refName] || schema;
|
|
253
|
+
}
|
|
254
|
+
return schema;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Generate the client.py content.
|
|
258
|
+
*/
|
|
259
|
+
function generateClientFile(procedures, typeInfo, baseUrl) {
|
|
260
|
+
const lines = [
|
|
261
|
+
"import httpx",
|
|
262
|
+
"import json",
|
|
263
|
+
"from urllib.parse import quote",
|
|
264
|
+
"from .models import *",
|
|
265
|
+
"",
|
|
266
|
+
"",
|
|
267
|
+
"class TRPCClient:",
|
|
268
|
+
' def __init__(self, base_url: str = "' + baseUrl + '"):',
|
|
269
|
+
' self.base_url = base_url.rstrip("/")',
|
|
270
|
+
" self.http = httpx.Client()",
|
|
271
|
+
"",
|
|
272
|
+
];
|
|
273
|
+
for (let i = 0; i < procedures.length; i++) {
|
|
274
|
+
const proc = procedures[i];
|
|
275
|
+
const info = typeInfo[i];
|
|
276
|
+
const snake = toSnakeCase(proc.name);
|
|
277
|
+
const hasInput = proc.inputSchema !== null;
|
|
278
|
+
const inputParam = hasInput ? `input: ${info.inputPyType}` : "";
|
|
279
|
+
const returnType = ` -> ${info.outputPyType}`;
|
|
280
|
+
lines.push(` def ${snake}(self, ${inputParam})${returnType}:`);
|
|
281
|
+
lines.push(` url = f"{self.base_url}/${proc.name}"`);
|
|
282
|
+
// Serialize input — models use .model_dump(), primitives are used directly
|
|
283
|
+
const inputExpr = hasInput
|
|
284
|
+
? info.inputIsModel
|
|
285
|
+
? "input.model_dump(by_alias=True)"
|
|
286
|
+
: "input"
|
|
287
|
+
: null;
|
|
288
|
+
if (proc.type === "query") {
|
|
289
|
+
if (inputExpr) {
|
|
290
|
+
lines.push(` encoded = quote(json.dumps(${inputExpr}))`);
|
|
291
|
+
lines.push(` resp = self.http.get(url, params={"input": encoded})`);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
lines.push(` resp = self.http.get(url)`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
if (inputExpr) {
|
|
299
|
+
lines.push(` resp = self.http.post(url, json={"json": ${inputExpr}})`);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
lines.push(` resp = self.http.post(url)`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
lines.push(` resp.raise_for_status()`);
|
|
306
|
+
lines.push(` data = resp.json()["result"]["data"]`);
|
|
307
|
+
if (info.outputIsModel) {
|
|
308
|
+
lines.push(` return ${info.outputPyType}(**data)`);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
lines.push(` return data`);
|
|
312
|
+
}
|
|
313
|
+
lines.push("");
|
|
314
|
+
}
|
|
315
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Generate the __init__.py content.
|
|
319
|
+
*/
|
|
320
|
+
function generateInitFile() {
|
|
321
|
+
return `from .models import *\nfrom .client import TRPCClient\n`;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Main codegen function — writes the generated Python package to disk.
|
|
325
|
+
*/
|
|
326
|
+
export function generatePythonClient(config, procedures) {
|
|
327
|
+
const outputDir = resolve(process.cwd(), config.output.dir);
|
|
328
|
+
const packageDir = join(outputDir, config.output.package_name);
|
|
329
|
+
mkdirSync(packageDir, { recursive: true });
|
|
330
|
+
const { content: modelsContent, typeInfo } = generateModelsFile(procedures);
|
|
331
|
+
writeFileSync(join(packageDir, "models.py"), modelsContent);
|
|
332
|
+
const clientContent = generateClientFile(procedures, typeInfo, config.router.base_url);
|
|
333
|
+
writeFileSync(join(packageDir, "client.py"), clientContent);
|
|
334
|
+
const initContent = generateInitFile();
|
|
335
|
+
writeFileSync(join(packageDir, "__init__.py"), initContent);
|
|
336
|
+
console.log(`Generated Python client at ${packageDir}/`);
|
|
337
|
+
console.log(` - models.py (${procedures.length} procedure(s))`);
|
|
338
|
+
console.log(` - client.py`);
|
|
339
|
+
console.log(` - __init__.py`);
|
|
340
|
+
}
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import TOML from "@iarna/toml";
|
|
4
|
+
export function loadConfig(configPath) {
|
|
5
|
+
const filePath = configPath
|
|
6
|
+
? resolve(configPath)
|
|
7
|
+
: resolve(process.cwd(), "trpc-gen-python.toml");
|
|
8
|
+
let raw;
|
|
9
|
+
try {
|
|
10
|
+
raw = readFileSync(filePath, "utf-8");
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error(`Config file not found: ${filePath}`);
|
|
14
|
+
}
|
|
15
|
+
const parsed = TOML.parse(raw);
|
|
16
|
+
if (!parsed.router?.path) {
|
|
17
|
+
throw new Error("Config missing required field: router.path");
|
|
18
|
+
}
|
|
19
|
+
if (!parsed.router?.base_url) {
|
|
20
|
+
throw new Error("Config missing required field: router.base_url");
|
|
21
|
+
}
|
|
22
|
+
if (!parsed.output?.dir) {
|
|
23
|
+
throw new Error("Config missing required field: output.dir");
|
|
24
|
+
}
|
|
25
|
+
if (!parsed.endpoints || parsed.endpoints.length === 0) {
|
|
26
|
+
throw new Error("Config must specify at least one endpoint pattern in endpoints = [...]");
|
|
27
|
+
}
|
|
28
|
+
parsed.router.export = parsed.router.export || "appRouter";
|
|
29
|
+
parsed.output.package_name = parsed.output.package_name || "trpc_client";
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { JSONSchema } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Use the TypeScript compiler to infer output types for procedures
|
|
4
|
+
* that don't have an explicit .output() Zod schema.
|
|
5
|
+
*
|
|
6
|
+
* Works by resolving `inferRouterOutputs<AppRouter>` and navigating
|
|
7
|
+
* the resulting type to extract each procedure's output type,
|
|
8
|
+
* then converting that TS type to JSON Schema.
|
|
9
|
+
*/
|
|
10
|
+
export declare function inferOutputTypes(routerPath: string, routerExport: string, procedureNames: string[]): Map<string, JSONSchema>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Project } from "ts-morph";
|
|
2
|
+
import { resolve, dirname } from "path";
|
|
3
|
+
import { accessSync } from "fs";
|
|
4
|
+
/**
|
|
5
|
+
* Use the TypeScript compiler to infer output types for procedures
|
|
6
|
+
* that don't have an explicit .output() Zod schema.
|
|
7
|
+
*
|
|
8
|
+
* Works by resolving `inferRouterOutputs<AppRouter>` and navigating
|
|
9
|
+
* the resulting type to extract each procedure's output type,
|
|
10
|
+
* then converting that TS type to JSON Schema.
|
|
11
|
+
*/
|
|
12
|
+
export function inferOutputTypes(routerPath, routerExport, procedureNames) {
|
|
13
|
+
const absRouterPath = resolve(process.cwd(), routerPath);
|
|
14
|
+
// Find the user's tsconfig
|
|
15
|
+
const tsConfigPath = findTsConfig(dirname(absRouterPath));
|
|
16
|
+
const project = new Project({
|
|
17
|
+
tsConfigFilePath: tsConfigPath,
|
|
18
|
+
skipAddingFilesFromTsConfig: true,
|
|
19
|
+
});
|
|
20
|
+
// Add the router file so the project can resolve it
|
|
21
|
+
project.addSourceFileAtPath(absRouterPath);
|
|
22
|
+
// Create a virtual file that uses inferRouterOutputs
|
|
23
|
+
const virtualSource = project.createSourceFile("__trpc_gen_virtual__.ts", `
|
|
24
|
+
import type { inferRouterOutputs } from "@trpc/server";
|
|
25
|
+
import type { ${routerExport} } from "${absRouterPath.replace(/\.ts$/, "")}";
|
|
26
|
+
type _AppRouter = typeof ${routerExport};
|
|
27
|
+
type _Outputs = inferRouterOutputs<_AppRouter>;
|
|
28
|
+
`, { overwrite: true });
|
|
29
|
+
const outputsAlias = virtualSource.getTypeAliasOrThrow("_Outputs");
|
|
30
|
+
const outputsType = outputsAlias.getType();
|
|
31
|
+
const results = new Map();
|
|
32
|
+
for (const procName of procedureNames) {
|
|
33
|
+
// "calls.getById" → navigate type via ["calls"]["getById"]
|
|
34
|
+
const segments = procName.split(".");
|
|
35
|
+
let currentType = outputsType;
|
|
36
|
+
let resolved = true;
|
|
37
|
+
for (const segment of segments) {
|
|
38
|
+
const prop = currentType.getProperty(segment);
|
|
39
|
+
if (!prop) {
|
|
40
|
+
resolved = false;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
currentType = prop.getTypeAtLocation(virtualSource);
|
|
44
|
+
}
|
|
45
|
+
if (resolved) {
|
|
46
|
+
const schema = tsTypeToJsonSchema(currentType, virtualSource, new Set());
|
|
47
|
+
results.set(procName, schema);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Find the nearest tsconfig.json starting from a directory.
|
|
54
|
+
*/
|
|
55
|
+
function findTsConfig(startDir) {
|
|
56
|
+
let dir = startDir;
|
|
57
|
+
while (true) {
|
|
58
|
+
const candidate = resolve(dir, "tsconfig.json");
|
|
59
|
+
try {
|
|
60
|
+
accessSync(candidate);
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
const parent = dirname(dir);
|
|
65
|
+
if (parent === dir) {
|
|
66
|
+
throw new Error(`Could not find tsconfig.json starting from ${startDir}`);
|
|
67
|
+
}
|
|
68
|
+
dir = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Convert a TypeScript type to a JSON Schema representation.
|
|
74
|
+
*/
|
|
75
|
+
function tsTypeToJsonSchema(type, location, seen) {
|
|
76
|
+
// Handle Promise<T> → unwrap to T
|
|
77
|
+
if (type.getSymbol()?.getName() === "Promise" || type.getText().startsWith("Promise<")) {
|
|
78
|
+
const typeArgs = type.getTypeArguments();
|
|
79
|
+
if (typeArgs.length > 0) {
|
|
80
|
+
return tsTypeToJsonSchema(typeArgs[0], location, seen);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Primitives
|
|
84
|
+
if (type.isString() || type.isStringLiteral()) {
|
|
85
|
+
return { type: "string" };
|
|
86
|
+
}
|
|
87
|
+
if (type.isNumber() || type.isNumberLiteral()) {
|
|
88
|
+
return { type: "number" };
|
|
89
|
+
}
|
|
90
|
+
if (type.isBoolean() || type.isBooleanLiteral()) {
|
|
91
|
+
return { type: "boolean" };
|
|
92
|
+
}
|
|
93
|
+
if (type.isNull()) {
|
|
94
|
+
return { type: "null" };
|
|
95
|
+
}
|
|
96
|
+
if (type.isUndefined()) {
|
|
97
|
+
return { type: "null" };
|
|
98
|
+
}
|
|
99
|
+
// Union types (but not boolean which shows as true | false)
|
|
100
|
+
if (type.isUnion() && !type.isBoolean()) {
|
|
101
|
+
const unionTypes = type.getUnionTypes();
|
|
102
|
+
// Filter out undefined for optional handling
|
|
103
|
+
const nonUndefined = unionTypes.filter((t) => !t.isUndefined());
|
|
104
|
+
const hasUndefined = unionTypes.some((t) => t.isUndefined());
|
|
105
|
+
if (nonUndefined.length === 1) {
|
|
106
|
+
const inner = tsTypeToJsonSchema(nonUndefined[0], location, seen);
|
|
107
|
+
if (hasUndefined) {
|
|
108
|
+
return { anyOf: [inner, { type: "null" }] };
|
|
109
|
+
}
|
|
110
|
+
return inner;
|
|
111
|
+
}
|
|
112
|
+
const schemas = nonUndefined.map((t) => tsTypeToJsonSchema(t, location, seen));
|
|
113
|
+
return { anyOf: schemas };
|
|
114
|
+
}
|
|
115
|
+
// Arrays
|
|
116
|
+
if (type.isArray()) {
|
|
117
|
+
const elementType = type.getArrayElementTypeOrThrow();
|
|
118
|
+
return {
|
|
119
|
+
type: "array",
|
|
120
|
+
items: tsTypeToJsonSchema(elementType, location, seen),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// Date → string
|
|
124
|
+
if (type.getSymbol()?.getName() === "Date") {
|
|
125
|
+
return { type: "string" };
|
|
126
|
+
}
|
|
127
|
+
// Object / interface types
|
|
128
|
+
if (type.isObject() && !type.isArray()) {
|
|
129
|
+
const typeText = type.getText();
|
|
130
|
+
// Guard against infinite recursion for recursive types
|
|
131
|
+
if (seen.has(typeText)) {
|
|
132
|
+
return { type: "object" };
|
|
133
|
+
}
|
|
134
|
+
seen.add(typeText);
|
|
135
|
+
const properties = {};
|
|
136
|
+
const required = [];
|
|
137
|
+
for (const prop of type.getProperties()) {
|
|
138
|
+
const propName = prop.getName();
|
|
139
|
+
// Skip internal/private properties
|
|
140
|
+
if (propName.startsWith("_"))
|
|
141
|
+
continue;
|
|
142
|
+
const propType = prop.getTypeAtLocation(location);
|
|
143
|
+
const isOptional = prop.isOptional();
|
|
144
|
+
let propSchema = tsTypeToJsonSchema(propType, location, new Set(seen));
|
|
145
|
+
// If the prop type is a union with undefined (from optional), unwrap it
|
|
146
|
+
if (isOptional && propSchema.anyOf) {
|
|
147
|
+
const nonNull = propSchema.anyOf.filter((s) => s.type !== "null");
|
|
148
|
+
if (nonNull.length === 1) {
|
|
149
|
+
propSchema = nonNull[0];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
properties[propName] = propSchema;
|
|
153
|
+
if (!isOptional) {
|
|
154
|
+
required.push(propName);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const schema = { type: "object", properties };
|
|
158
|
+
if (required.length > 0) {
|
|
159
|
+
schema.required = required;
|
|
160
|
+
}
|
|
161
|
+
return schema;
|
|
162
|
+
}
|
|
163
|
+
// Fallback
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { resolve, dirname } from "path";
|
|
2
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
3
|
+
import { inferOutputTypes } from "./infer-outputs.js";
|
|
4
|
+
export async function introspectRouter(config) {
|
|
5
|
+
const routerPath = resolve(process.cwd(), config.router.path);
|
|
6
|
+
// Use tsx to register TypeScript loader before importing
|
|
7
|
+
const tsxPath = resolve(dirname(new URL(import.meta.url).pathname), "..", "node_modules", "tsx", "esm", "api.mjs");
|
|
8
|
+
// Register tsx so we can import .ts files
|
|
9
|
+
try {
|
|
10
|
+
const tsx = await import(tsxPath);
|
|
11
|
+
if (tsx.register) {
|
|
12
|
+
tsx.register();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// tsx may already be registered if running via tsx
|
|
17
|
+
}
|
|
18
|
+
const routerModule = await import(routerPath);
|
|
19
|
+
const router = routerModule[config.router.export];
|
|
20
|
+
if (!router) {
|
|
21
|
+
throw new Error(`Export "${config.router.export}" not found in ${config.router.path}`);
|
|
22
|
+
}
|
|
23
|
+
const procedures = router._def.procedures;
|
|
24
|
+
const allProcNames = Object.keys(procedures);
|
|
25
|
+
// Resolve glob patterns against the full procedure list
|
|
26
|
+
const matchedNames = resolveEndpointPatterns(config.endpoints, allProcNames);
|
|
27
|
+
if (matchedNames.length === 0) {
|
|
28
|
+
throw new Error(`No procedures matched the patterns: ${config.endpoints.join(", ")}. Available: ${allProcNames.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
const results = [];
|
|
31
|
+
// First pass: extract runtime schemas and identify procedures missing output
|
|
32
|
+
const needsInference = [];
|
|
33
|
+
for (const name of matchedNames) {
|
|
34
|
+
const procedure = procedures[name];
|
|
35
|
+
const procDef = procedure._def;
|
|
36
|
+
const procedureType = procDef.type === "mutation" ? "mutation" : "query";
|
|
37
|
+
// Extract input schema (tRPC v11 uses inputs array)
|
|
38
|
+
let inputSchema = null;
|
|
39
|
+
const inputs = procDef.inputs;
|
|
40
|
+
if (inputs && inputs.length > 0) {
|
|
41
|
+
if (inputs.length === 1) {
|
|
42
|
+
inputSchema = zodToJsonSchema(inputs[0], {
|
|
43
|
+
target: "jsonSchema7",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const schemas = inputs.map((i) => zodToJsonSchema(i, { target: "jsonSchema7" }));
|
|
48
|
+
inputSchema = mergeObjectSchemas(schemas);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Extract output schema from explicit .output()
|
|
52
|
+
let outputSchema = null;
|
|
53
|
+
if (procDef.output) {
|
|
54
|
+
outputSchema = zodToJsonSchema(procDef.output, {
|
|
55
|
+
target: "jsonSchema7",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
needsInference.push(name);
|
|
60
|
+
}
|
|
61
|
+
results.push({
|
|
62
|
+
name,
|
|
63
|
+
type: procedureType,
|
|
64
|
+
inputSchema,
|
|
65
|
+
outputSchema,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// Second pass: use TS compiler to infer output types for procedures without .output()
|
|
69
|
+
if (needsInference.length > 0) {
|
|
70
|
+
console.log(`Inferring output types via TypeScript compiler for: ${needsInference.join(", ")}`);
|
|
71
|
+
const inferred = inferOutputTypes(config.router.path, config.router.export, needsInference);
|
|
72
|
+
for (const result of results) {
|
|
73
|
+
if (!result.outputSchema && inferred.has(result.name)) {
|
|
74
|
+
result.outputSchema = inferred.get(result.name);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Convert a glob-like endpoint pattern to a RegExp.
|
|
82
|
+
* Supports:
|
|
83
|
+
* - "*" matches any characters within a single segment (between dots)
|
|
84
|
+
* - "**" matches any number of segments (including zero)
|
|
85
|
+
* - Exact names match literally
|
|
86
|
+
*
|
|
87
|
+
* Examples:
|
|
88
|
+
* "user.*" → matches "user.getById", "user.create"
|
|
89
|
+
* "user.**" → matches "user.getById", "user.profile.update"
|
|
90
|
+
* "*.list" → matches "post.list", "comment.list"
|
|
91
|
+
* "**" → matches everything
|
|
92
|
+
* "user.getById" → matches exactly "user.getById"
|
|
93
|
+
*/
|
|
94
|
+
function patternToRegex(pattern) {
|
|
95
|
+
const escaped = pattern
|
|
96
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex chars (dot included)
|
|
97
|
+
.replace(/\\\.\*\*/g, "(?:\\.[^.]+)*") // ".**" → zero or more .segments
|
|
98
|
+
.replace(/\*\*/g, ".*") // standalone "**" → anything
|
|
99
|
+
.replace(/\*/g, "[^.]+"); // "*" → one segment (no dots)
|
|
100
|
+
return new RegExp(`^${escaped}$`);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve an array of glob patterns against the full list of procedure names.
|
|
104
|
+
* Returns a deduplicated list in stable order (matching the order procedures appear in the router).
|
|
105
|
+
*/
|
|
106
|
+
function resolveEndpointPatterns(patterns, allNames) {
|
|
107
|
+
const matched = new Set();
|
|
108
|
+
for (const pattern of patterns) {
|
|
109
|
+
const regex = patternToRegex(pattern);
|
|
110
|
+
let found = false;
|
|
111
|
+
for (const name of allNames) {
|
|
112
|
+
if (regex.test(name)) {
|
|
113
|
+
matched.add(name);
|
|
114
|
+
found = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!found) {
|
|
118
|
+
console.warn(`Warning: pattern "${pattern}" did not match any procedures`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Return in the order they appear in the router
|
|
122
|
+
return allNames.filter((name) => matched.has(name));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Merge multiple JSON Schemas (all assumed to be objects) into one
|
|
126
|
+
* by combining their properties and required arrays.
|
|
127
|
+
*/
|
|
128
|
+
function mergeObjectSchemas(schemas) {
|
|
129
|
+
const merged = {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: {},
|
|
132
|
+
required: [],
|
|
133
|
+
};
|
|
134
|
+
for (const schema of schemas) {
|
|
135
|
+
if (schema.properties) {
|
|
136
|
+
Object.assign(merged.properties, schema.properties);
|
|
137
|
+
}
|
|
138
|
+
if (schema.required) {
|
|
139
|
+
merged.required.push(...schema.required);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (merged.required.length === 0) {
|
|
143
|
+
delete merged.required;
|
|
144
|
+
}
|
|
145
|
+
return merged;
|
|
146
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface RouterConfig {
|
|
2
|
+
path: string;
|
|
3
|
+
export: string;
|
|
4
|
+
base_url: string;
|
|
5
|
+
}
|
|
6
|
+
export interface OutputConfig {
|
|
7
|
+
dir: string;
|
|
8
|
+
package_name: string;
|
|
9
|
+
}
|
|
10
|
+
export interface Config {
|
|
11
|
+
router: RouterConfig;
|
|
12
|
+
output: OutputConfig;
|
|
13
|
+
endpoints: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface JSONSchema {
|
|
16
|
+
type?: string;
|
|
17
|
+
properties?: Record<string, JSONSchema>;
|
|
18
|
+
required?: string[];
|
|
19
|
+
items?: JSONSchema;
|
|
20
|
+
enum?: unknown[];
|
|
21
|
+
$ref?: string;
|
|
22
|
+
definitions?: Record<string, JSONSchema>;
|
|
23
|
+
anyOf?: JSONSchema[];
|
|
24
|
+
allOf?: JSONSchema[];
|
|
25
|
+
oneOf?: JSONSchema[];
|
|
26
|
+
default?: unknown;
|
|
27
|
+
description?: string;
|
|
28
|
+
additionalProperties?: boolean | JSONSchema;
|
|
29
|
+
nullable?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface ProcedureInfo {
|
|
32
|
+
name: string;
|
|
33
|
+
type: "query" | "mutation";
|
|
34
|
+
inputSchema: JSONSchema | null;
|
|
35
|
+
outputSchema: JSONSchema | null;
|
|
36
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "trpc-gen-python",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate Python (Pydantic + httpx) clients from tRPC routers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/cli.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"trpc-gen-python": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "tsx src/cli.ts",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"trpc",
|
|
21
|
+
"python",
|
|
22
|
+
"codegen",
|
|
23
|
+
"pydantic",
|
|
24
|
+
"httpx",
|
|
25
|
+
"type-safe",
|
|
26
|
+
"api-client"
|
|
27
|
+
],
|
|
28
|
+
"author": "Ishan Das Sharma",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/sad-pixel/trpc-gen-python.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/sad-pixel/trpc-gen-python/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/sad-pixel/trpc-gen-python#readme",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@iarna/toml": "^2.2.5",
|
|
43
|
+
"commander": "^12.1.0",
|
|
44
|
+
"ts-morph": "^27.0.2",
|
|
45
|
+
"tsx": "^4.19.0",
|
|
46
|
+
"zod-to-json-schema": "^3.23.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@trpc/server": "^11.0.0",
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"typescript": "^5.6.0",
|
|
52
|
+
"zod": "^3.23.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@trpc/server": "^11.0.0",
|
|
56
|
+
"zod": "^3.23.0"
|
|
57
|
+
}
|
|
58
|
+
}
|