openapi-multitarget-codegen 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/LICENSE +21 -0
- package/README.md +394 -0
- package/openapi/openapi.example.yaml +85 -0
- package/package.json +46 -0
- package/src/cli.mjs +735 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# openapi-multitarget-codegen
|
|
2
|
+
|
|
3
|
+
OpenAPI 스펙을 입력으로 받아 아래 3가지 타깃의 코드를 생성하는 **npm 배포용 CLI 패키지**입니다.
|
|
4
|
+
|
|
5
|
+
1. **TypeScript Kubb**
|
|
6
|
+
2. **Python 타입 코드**
|
|
7
|
+
3. **LangGraph 도구 코드**
|
|
8
|
+
|
|
9
|
+
이 패키지는 특정 프로젝트 구조를 전제하지 않습니다.
|
|
10
|
+
|
|
11
|
+
- 입력 스펙 경로는 `--input`으로 받습니다.
|
|
12
|
+
- 출력 루트 경로는 `--output`으로 받습니다.
|
|
13
|
+
- 생성 타깃은 `--target`으로 받습니다.
|
|
14
|
+
|
|
15
|
+
즉, `openapi/openapi.yaml`, `generated/...` 같은 하드코딩된 경로에 묶이지 않고 사용할 수 있게 만드는 것이 목적입니다.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 설치
|
|
20
|
+
|
|
21
|
+
### dev dependency
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -D openapi-multitarget-codegen
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
또는:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm add -D openapi-multitarget-codegen
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 전역 설치
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install -g openapi-multitarget-codegen
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 문서
|
|
42
|
+
|
|
43
|
+
- OpenAPI 작성 가이드: [`docs/openapi-authoring-guide.md`](docs/openapi-authoring-guide.md)
|
|
44
|
+
- 예제 스펙: [`openapi/openapi.example.yaml`](openapi/openapi.example.yaml)
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## GitHub
|
|
49
|
+
|
|
50
|
+
- Repository: `https://github.com/kht2199/openapi-multitarget-codegen`
|
|
51
|
+
- Issues: `https://github.com/kht2199/openapi-multitarget-codegen/issues`
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 지원 타깃
|
|
56
|
+
|
|
57
|
+
### 1) `kubb`
|
|
58
|
+
OpenAPI 스펙으로부터 TypeScript 타입 및 fetch client를 생성합니다.
|
|
59
|
+
|
|
60
|
+
### 2) `python`
|
|
61
|
+
OpenAPI 스펙으로부터 Python 타입 코드를 생성합니다.
|
|
62
|
+
기본 출력 모델 타입은 `pydantic_v2.BaseModel`입니다.
|
|
63
|
+
|
|
64
|
+
### 3) `langgraph`
|
|
65
|
+
OpenAPI operation을 기준으로 LangGraph/LangChain StructuredTool 초안 코드를 생성합니다.
|
|
66
|
+
|
|
67
|
+
중요:
|
|
68
|
+
- **LangGraph는 정식 지원 타깃입니다.**
|
|
69
|
+
- 하지만 구현이 특정 파일 경로나 특정 프로젝트 레이아웃에 하드코딩되어서는 안 됩니다.
|
|
70
|
+
- 이 CLI는 LangGraph 타깃도 다른 타깃과 동일하게 인자 기반으로 생성합니다.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## CLI 사용법
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
openapi-multitarget-codegen generate --input <spec> --output <dir> --target <name>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 공통 옵션
|
|
81
|
+
|
|
82
|
+
- `--input <path>`: OpenAPI 파일 경로 (`.yaml`, `.yml`, `.json`)
|
|
83
|
+
- `--output <dir>`: 생성 결과를 쓸 출력 루트 디렉터리
|
|
84
|
+
- `--target <name>`: 생성 타깃 (`kubb`, `python`, `langgraph`)
|
|
85
|
+
- 여러 개를 생성하려면 `--target`을 반복해서 넘깁니다.
|
|
86
|
+
- `--clean`: 출력 루트를 먼저 비웁니다.
|
|
87
|
+
|
|
88
|
+
### 추가 옵션
|
|
89
|
+
|
|
90
|
+
- `--python-model-type <type>`
|
|
91
|
+
- 기본값: `pydantic_v2.BaseModel`
|
|
92
|
+
- `--kubb-output-dir <path>`
|
|
93
|
+
- 기본값: `<output>/kubb`
|
|
94
|
+
- 상대 경로는 `--output` 기준으로 해석됩니다.
|
|
95
|
+
- `--python-output-file <path>`
|
|
96
|
+
- 기본값: `<output>/python/models.py`
|
|
97
|
+
- 상대 경로는 `--output` 기준으로 해석됩니다.
|
|
98
|
+
- `--langgraph-output-dir <path>`
|
|
99
|
+
- 기본값: `<output>/langgraph`
|
|
100
|
+
- 상대 경로는 `--output` 기준으로 해석됩니다.
|
|
101
|
+
- `--langgraph-file <name>`
|
|
102
|
+
- 기본값: `langgraph_tools.py`
|
|
103
|
+
- `--kubb-client <client>`
|
|
104
|
+
- 기본값: `fetch`
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 빠른 OpenAPI 작성 템플릿
|
|
109
|
+
|
|
110
|
+
아래 템플릿은 이 CLI로 Kubb / Python / LangGraph 생성을 시작할 때 최소한으로 복붙해 쓸 수 있는 예시입니다.
|
|
111
|
+
더 자세한 기준은 [`docs/openapi-authoring-guide.md`](docs/openapi-authoring-guide.md)를 참고하세요.
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
openapi: 3.0.3
|
|
115
|
+
info:
|
|
116
|
+
title: Example API
|
|
117
|
+
version: 0.1.0
|
|
118
|
+
servers:
|
|
119
|
+
- url: https://api.example.com
|
|
120
|
+
paths:
|
|
121
|
+
/items:
|
|
122
|
+
get:
|
|
123
|
+
operationId: listItems
|
|
124
|
+
summary: List items
|
|
125
|
+
description: Retrieve the current item catalog.
|
|
126
|
+
responses:
|
|
127
|
+
'200':
|
|
128
|
+
description: Successful item list response
|
|
129
|
+
content:
|
|
130
|
+
application/json:
|
|
131
|
+
schema:
|
|
132
|
+
type: array
|
|
133
|
+
items:
|
|
134
|
+
$ref: '#/components/schemas/Item'
|
|
135
|
+
post:
|
|
136
|
+
operationId: createItem
|
|
137
|
+
summary: Create item
|
|
138
|
+
description: Create a new catalog item from the supplied payload.
|
|
139
|
+
requestBody:
|
|
140
|
+
required: true
|
|
141
|
+
description: Item payload to create.
|
|
142
|
+
content:
|
|
143
|
+
application/json:
|
|
144
|
+
schema:
|
|
145
|
+
$ref: '#/components/schemas/CreateItemRequest'
|
|
146
|
+
responses:
|
|
147
|
+
'200':
|
|
148
|
+
description: Item created successfully
|
|
149
|
+
content:
|
|
150
|
+
application/json:
|
|
151
|
+
schema:
|
|
152
|
+
$ref: '#/components/schemas/Item'
|
|
153
|
+
/items/{itemId}:
|
|
154
|
+
get:
|
|
155
|
+
operationId: getItem
|
|
156
|
+
summary: Get item by id
|
|
157
|
+
description: Retrieve one catalog item using its identifier.
|
|
158
|
+
parameters:
|
|
159
|
+
- in: path
|
|
160
|
+
name: itemId
|
|
161
|
+
required: true
|
|
162
|
+
description: Unique item identifier.
|
|
163
|
+
schema:
|
|
164
|
+
type: string
|
|
165
|
+
responses:
|
|
166
|
+
'200':
|
|
167
|
+
description: Matching item response
|
|
168
|
+
content:
|
|
169
|
+
application/json:
|
|
170
|
+
schema:
|
|
171
|
+
$ref: '#/components/schemas/Item'
|
|
172
|
+
components:
|
|
173
|
+
schemas:
|
|
174
|
+
Item:
|
|
175
|
+
type: object
|
|
176
|
+
description: Catalog item returned by the API.
|
|
177
|
+
required: [id, name]
|
|
178
|
+
properties:
|
|
179
|
+
id:
|
|
180
|
+
type: string
|
|
181
|
+
description: Stable item identifier.
|
|
182
|
+
name:
|
|
183
|
+
type: string
|
|
184
|
+
description: Display name of the item.
|
|
185
|
+
description:
|
|
186
|
+
type: string
|
|
187
|
+
description: Optional human-readable item details.
|
|
188
|
+
CreateItemRequest:
|
|
189
|
+
type: object
|
|
190
|
+
description: Payload for creating a catalog item.
|
|
191
|
+
required: [name]
|
|
192
|
+
properties:
|
|
193
|
+
name:
|
|
194
|
+
type: string
|
|
195
|
+
description: Name to assign to the new item.
|
|
196
|
+
description:
|
|
197
|
+
type: string
|
|
198
|
+
description: Optional explanatory text for the new item.
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
체크 포인트:
|
|
202
|
+
- 각 operation에 `operationId`, `summary`, `description`을 넣기
|
|
203
|
+
- response `description`을 비워두지 않기
|
|
204
|
+
- parameter/property마다 `description`을 넣기
|
|
205
|
+
- request/response schema는 가능하면 `components.schemas`로 분리하기
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 예시
|
|
210
|
+
|
|
211
|
+
### Kubb만 생성
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
openapi-multitarget-codegen generate \
|
|
215
|
+
--input ./specs/openapi.yaml \
|
|
216
|
+
--output ./generated \
|
|
217
|
+
--target kubb
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
예시 출력 위치 (`--output ./generated`를 준 경우):
|
|
221
|
+
- `./generated/kubb/...`
|
|
222
|
+
|
|
223
|
+
### Python 타입만 생성
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
openapi-multitarget-codegen generate \
|
|
227
|
+
--input ./specs/openapi.yaml \
|
|
228
|
+
--output ./generated \
|
|
229
|
+
--target python \
|
|
230
|
+
--python-model-type pydantic_v2.BaseModel
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
예시 출력 위치 (`--output ./generated`를 준 경우):
|
|
234
|
+
- `./generated/python/models.py`
|
|
235
|
+
|
|
236
|
+
### 출력 경로까지 세분화해서 생성
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
openapi-multitarget-codegen generate \
|
|
240
|
+
--input ./specs/openapi.yaml \
|
|
241
|
+
--output ./generated \
|
|
242
|
+
--target kubb \
|
|
243
|
+
--target python \
|
|
244
|
+
--target langgraph \
|
|
245
|
+
--kubb-output-dir ./sdk/ts \
|
|
246
|
+
--python-output-file ./sdk/python/api_models.py \
|
|
247
|
+
--langgraph-output-dir ./agent/tools \
|
|
248
|
+
--langgraph-file catalog_tools.py
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
위 예시는 다음처럼 생성됩니다.
|
|
252
|
+
- `./generated/sdk/ts/...`
|
|
253
|
+
- `./generated/sdk/python/api_models.py`
|
|
254
|
+
- `./generated/agent/tools/catalog_tools.py`
|
|
255
|
+
|
|
256
|
+
### LangGraph 코드만 생성
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
openapi-multitarget-codegen generate \
|
|
260
|
+
--input ./specs/openapi.yaml \
|
|
261
|
+
--output ./generated \
|
|
262
|
+
--target langgraph
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
예시 출력 위치 (`--output ./generated`를 준 경우):
|
|
266
|
+
- `./generated/langgraph/langgraph_tools.py`
|
|
267
|
+
|
|
268
|
+
### 여러 타깃 동시 생성
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
openapi-multitarget-codegen generate \
|
|
272
|
+
--input ./specs/openapi.yaml \
|
|
273
|
+
--output ./generated \
|
|
274
|
+
--target kubb \
|
|
275
|
+
--target python \
|
|
276
|
+
--target langgraph \
|
|
277
|
+
--clean
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
예시 출력 위치 (`--output ./generated`를 준 경우):
|
|
281
|
+
- `./generated/kubb/...`
|
|
282
|
+
- `./generated/python/models.py`
|
|
283
|
+
- `./generated/langgraph/langgraph_tools.py`
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## 스펙 유효성 확인
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
openapi-multitarget-codegen check-spec --input ./specs/openapi.yaml
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
이 명령은 다음을 검사합니다.
|
|
294
|
+
- 파일 존재 여부
|
|
295
|
+
- `openapi` 필드 존재 여부
|
|
296
|
+
- `paths` 객체 존재 여부
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## 현재 출력 정책
|
|
301
|
+
|
|
302
|
+
출력 루트는 `--output`으로 받고, 아무 추가 옵션이 없으면 기본값은 아래와 같습니다.
|
|
303
|
+
|
|
304
|
+
- `kubb` → `<output>/kubb`
|
|
305
|
+
- `python` → `<output>/python/models.py`
|
|
306
|
+
- `langgraph` → `<output>/langgraph/<langgraph-file>`
|
|
307
|
+
|
|
308
|
+
필요하면 아래 옵션으로 타깃별 경로를 더 세분화할 수 있습니다.
|
|
309
|
+
|
|
310
|
+
- `--kubb-output-dir`
|
|
311
|
+
- `--python-output-file`
|
|
312
|
+
- `--langgraph-output-dir`
|
|
313
|
+
|
|
314
|
+
상대 경로는 모두 `--output` 기준으로 해석되고, 절대 경로도 지원합니다.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## 현재 구현 특징
|
|
319
|
+
|
|
320
|
+
### Kubb
|
|
321
|
+
- 임시 Kubb config를 생성해서 실행합니다.
|
|
322
|
+
- 입력 스펙 경로와 출력 경로를 동적으로 주입합니다.
|
|
323
|
+
- 기본 client는 `fetch`입니다.
|
|
324
|
+
|
|
325
|
+
### Python
|
|
326
|
+
- `uvx --from datamodel-code-generator datamodel-codegen ...` 기반으로 실행합니다.
|
|
327
|
+
- 모델 타입은 `--python-model-type`으로 바꿀 수 있습니다.
|
|
328
|
+
|
|
329
|
+
### LangGraph
|
|
330
|
+
- OpenAPI operation을 순회하면서 StructuredTool 초안 코드를 생성합니다.
|
|
331
|
+
- path/query/body를 분리해서 처리하는 기본 골격을 포함합니다.
|
|
332
|
+
- 출력 파일명은 `--langgraph-file`로 조절할 수 있습니다.
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## 왜 이 패키지가 필요한가
|
|
337
|
+
|
|
338
|
+
실무에서는 같은 OpenAPI 스펙을 기준으로 여러 언어/프레임워크용 코드를 동시에 만들고 싶을 때가 많습니다.
|
|
339
|
+
|
|
340
|
+
예를 들면:
|
|
341
|
+
- 프론트엔드는 TypeScript client가 필요하고
|
|
342
|
+
- Python 서버/스크립트는 타입 모델이 필요하고
|
|
343
|
+
- 에이전트 계층은 LangGraph 도구 코드가 필요할 수 있습니다.
|
|
344
|
+
|
|
345
|
+
이 패키지는 그런 상황에서 OpenAPI를 단일 source of truth로 두고, 여러 타깃을 하나의 CLI 인터페이스로 정리하는 데 목적이 있습니다.
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## 예제 검증 스크립트
|
|
350
|
+
|
|
351
|
+
저장소에는 예제 스펙 기반 검증 스크립트가 들어 있습니다.
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
npm run check:example-spec
|
|
355
|
+
npm run generate:example:kubb
|
|
356
|
+
npm run generate:example:python
|
|
357
|
+
npm run generate:example:langgraph
|
|
358
|
+
npm run generate:example:all
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
예제 출력은 `./.tmp-output` 아래에 생성됩니다.
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## 런타임 요구사항
|
|
366
|
+
|
|
367
|
+
- Node.js 20+
|
|
368
|
+
- npm 또는 pnpm
|
|
369
|
+
- Python 3.11+
|
|
370
|
+
- `uvx` 사용 가능 환경 (`python` 타깃용, 예: `brew install uv`)
|
|
371
|
+
|
|
372
|
+
LangGraph 생성 결과를 실제로 실행하려면 별도로 아래 Python 패키지가 필요할 수 있습니다.
|
|
373
|
+
- `httpx`
|
|
374
|
+
- `pydantic`
|
|
375
|
+
- `langchain-core`
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## 아직 남아 있는 확장 포인트
|
|
380
|
+
|
|
381
|
+
향후 아래 항목은 더 발전시킬 수 있습니다.
|
|
382
|
+
|
|
383
|
+
- Kubb의 React Query / Zod / MSW 확장 옵션
|
|
384
|
+
- Python 출력의 dataclass / TypedDict preset 단순화
|
|
385
|
+
- LangGraph naming / auth / filtering 전략 옵션화
|
|
386
|
+
- spec lint/report 전용 명령 추가
|
|
387
|
+
- dry-run / overwrite 정책 옵션
|
|
388
|
+
- npm publish용 release workflow 정리
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
## 한 줄 요약
|
|
393
|
+
|
|
394
|
+
**openapi-multitarget-codegen은 OpenAPI 스펙을 입력받아 Kubb, Python 타입, LangGraph 코드를 생성하는 인자 기반 npm CLI 패키지입니다.**
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
openapi: 3.0.3
|
|
2
|
+
info:
|
|
3
|
+
title: Example API
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
servers:
|
|
6
|
+
- url: https://api.example.com
|
|
7
|
+
paths:
|
|
8
|
+
/items:
|
|
9
|
+
get:
|
|
10
|
+
operationId: listItems
|
|
11
|
+
summary: List items
|
|
12
|
+
description: Retrieve the current item catalog.
|
|
13
|
+
responses:
|
|
14
|
+
'200':
|
|
15
|
+
description: Successful item list response
|
|
16
|
+
content:
|
|
17
|
+
application/json:
|
|
18
|
+
schema:
|
|
19
|
+
type: array
|
|
20
|
+
items:
|
|
21
|
+
$ref: '#/components/schemas/Item'
|
|
22
|
+
post:
|
|
23
|
+
operationId: createItem
|
|
24
|
+
summary: Create item
|
|
25
|
+
description: Create a new catalog item from the supplied payload.
|
|
26
|
+
requestBody:
|
|
27
|
+
required: true
|
|
28
|
+
description: Item payload to create.
|
|
29
|
+
content:
|
|
30
|
+
application/json:
|
|
31
|
+
schema:
|
|
32
|
+
$ref: '#/components/schemas/CreateItemRequest'
|
|
33
|
+
responses:
|
|
34
|
+
'200':
|
|
35
|
+
description: Item created successfully
|
|
36
|
+
content:
|
|
37
|
+
application/json:
|
|
38
|
+
schema:
|
|
39
|
+
$ref: '#/components/schemas/Item'
|
|
40
|
+
/items/{itemId}:
|
|
41
|
+
get:
|
|
42
|
+
operationId: getItem
|
|
43
|
+
summary: Get item by id
|
|
44
|
+
description: Retrieve one catalog item using its identifier.
|
|
45
|
+
parameters:
|
|
46
|
+
- in: path
|
|
47
|
+
name: itemId
|
|
48
|
+
required: true
|
|
49
|
+
description: Unique item identifier.
|
|
50
|
+
schema:
|
|
51
|
+
type: string
|
|
52
|
+
responses:
|
|
53
|
+
'200':
|
|
54
|
+
description: Matching item response
|
|
55
|
+
content:
|
|
56
|
+
application/json:
|
|
57
|
+
schema:
|
|
58
|
+
$ref: '#/components/schemas/Item'
|
|
59
|
+
components:
|
|
60
|
+
schemas:
|
|
61
|
+
Item:
|
|
62
|
+
type: object
|
|
63
|
+
description: Catalog item returned by the API.
|
|
64
|
+
required: [id, name]
|
|
65
|
+
properties:
|
|
66
|
+
id:
|
|
67
|
+
type: string
|
|
68
|
+
description: Stable item identifier.
|
|
69
|
+
name:
|
|
70
|
+
type: string
|
|
71
|
+
description: Display name of the item.
|
|
72
|
+
description:
|
|
73
|
+
type: string
|
|
74
|
+
description: Optional human-readable item details.
|
|
75
|
+
CreateItemRequest:
|
|
76
|
+
type: object
|
|
77
|
+
description: Payload for creating a catalog item.
|
|
78
|
+
required: [name]
|
|
79
|
+
properties:
|
|
80
|
+
name:
|
|
81
|
+
type: string
|
|
82
|
+
description: Name to assign to the new item.
|
|
83
|
+
description:
|
|
84
|
+
type: string
|
|
85
|
+
description: Optional explanatory text for the new item.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openapi-multitarget-codegen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OpenAPI-based multi-target code generation CLI for Kubb, Python types, and LangGraph tools",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/kht2199/openapi-multitarget-codegen.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/kht2199/openapi-multitarget-codegen#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/kht2199/openapi-multitarget-codegen/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"openapi-multitarget-codegen": "src/cli.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"openapi/openapi.example.yaml"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"clean": "rimraf .tmp-output",
|
|
29
|
+
"check:example-spec": "node ./src/cli.mjs check-spec --input ./openapi/openapi.example.yaml",
|
|
30
|
+
"generate:example:kubb": "node ./src/cli.mjs generate --input ./openapi/openapi.example.yaml --output ./.tmp-output --target kubb --clean",
|
|
31
|
+
"generate:example:python": "node ./src/cli.mjs generate --input ./openapi/openapi.example.yaml --output ./.tmp-output --target python --clean",
|
|
32
|
+
"generate:example:langgraph": "node ./src/cli.mjs generate --input ./openapi/openapi.example.yaml --output ./.tmp-output --target langgraph --clean",
|
|
33
|
+
"generate:example:all": "node ./src/cli.mjs generate --input ./openapi/openapi.example.yaml --output ./.tmp-output --target kubb --target python --target langgraph --clean"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@kubb/cli": "^3.12.0",
|
|
37
|
+
"@kubb/core": "^3.12.0",
|
|
38
|
+
"@kubb/plugin-client": "^3.12.0",
|
|
39
|
+
"@kubb/plugin-oas": "^3.12.0",
|
|
40
|
+
"@kubb/plugin-ts": "^3.12.0",
|
|
41
|
+
"js-yaml": "^4.1.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"rimraf": "^6.0.1"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import yaml from 'js-yaml'
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url)
|
|
12
|
+
const CLI_FILE = fileURLToPath(import.meta.url)
|
|
13
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(CLI_FILE), '..')
|
|
14
|
+
|
|
15
|
+
function main() {
|
|
16
|
+
const { command, options } = parseArgs(process.argv.slice(2))
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
if (!command || command === 'help' || options.help) {
|
|
20
|
+
printHelp()
|
|
21
|
+
process.exit(0)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (command === 'check-spec') {
|
|
25
|
+
const input = requireOption(options, 'input')
|
|
26
|
+
validateOpenApiFile(path.resolve(input))
|
|
27
|
+
console.log(`✓ valid OpenAPI spec: ${path.resolve(input)}`)
|
|
28
|
+
process.exit(0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (command !== 'generate') {
|
|
32
|
+
throw new Error(`Unsupported command: ${command}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const input = path.resolve(requireOption(options, 'input'))
|
|
36
|
+
const output = path.resolve(requireOption(options, 'output'))
|
|
37
|
+
const targets = normalizeTargets(options.target)
|
|
38
|
+
const pythonModelType = options['python-model-type'] || 'pydantic_v2.BaseModel'
|
|
39
|
+
const kubbOutputDir = options['kubb-output-dir']
|
|
40
|
+
const pythonOutputFile = options['python-output-file']
|
|
41
|
+
const langgraphOutputDir = options['langgraph-output-dir']
|
|
42
|
+
const langgraphFileName = options['langgraph-file'] || 'langgraph_tools.py'
|
|
43
|
+
const kubbClient = options['kubb-client'] || 'fetch'
|
|
44
|
+
const clean = Boolean(options.clean)
|
|
45
|
+
|
|
46
|
+
validateOpenApiFile(input)
|
|
47
|
+
|
|
48
|
+
if (clean) {
|
|
49
|
+
guardCleanPath(output)
|
|
50
|
+
}
|
|
51
|
+
if (clean && fs.existsSync(output)) {
|
|
52
|
+
fs.rmSync(output, { recursive: true, force: true })
|
|
53
|
+
}
|
|
54
|
+
fs.mkdirSync(output, { recursive: true })
|
|
55
|
+
|
|
56
|
+
for (const target of targets) {
|
|
57
|
+
if (target === 'kubb') {
|
|
58
|
+
runKubb({ input, outputRoot: output, outputDir: kubbOutputDir, client: kubbClient })
|
|
59
|
+
} else if (target === 'python') {
|
|
60
|
+
runPython({ input, outputRoot: output, outputFile: pythonOutputFile, modelType: pythonModelType })
|
|
61
|
+
} else if (target === 'langgraph') {
|
|
62
|
+
runLangGraph({ input, outputRoot: output, outputDir: langgraphOutputDir, fileName: langgraphFileName })
|
|
63
|
+
} else {
|
|
64
|
+
throw new Error(`Unsupported target: ${target}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(`✗ ${error.message}`)
|
|
69
|
+
process.exit(1)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseArgs(argv) {
|
|
74
|
+
const args = [...argv]
|
|
75
|
+
const first = args[0]
|
|
76
|
+
const command = !first || first.startsWith('--') ? undefined : args.shift()
|
|
77
|
+
const options = {}
|
|
78
|
+
|
|
79
|
+
while (args.length > 0) {
|
|
80
|
+
const token = args.shift()
|
|
81
|
+
if (!token.startsWith('--')) {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const key = token.slice(2)
|
|
86
|
+
if (key === 'help' || key === 'clean') {
|
|
87
|
+
options[key] = true
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const value = args.shift()
|
|
92
|
+
if (!value || value.startsWith('--')) {
|
|
93
|
+
throw new Error(`Missing value for --${key}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (key === 'target') {
|
|
97
|
+
if (!options.target) options.target = []
|
|
98
|
+
options.target.push(value)
|
|
99
|
+
} else {
|
|
100
|
+
options[key] = value
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { command, options }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeTargets(targetValue) {
|
|
108
|
+
const values = Array.isArray(targetValue) ? targetValue : targetValue ? [targetValue] : []
|
|
109
|
+
if (values.length === 0) {
|
|
110
|
+
throw new Error('At least one --target is required (kubb | python | langgraph)')
|
|
111
|
+
}
|
|
112
|
+
return values
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function requireOption(options, key) {
|
|
116
|
+
const value = options[key]
|
|
117
|
+
if (!value) {
|
|
118
|
+
throw new Error(`--${key} is required`)
|
|
119
|
+
}
|
|
120
|
+
return value
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function validateOpenApiFile(specPath) {
|
|
124
|
+
if (!fs.existsSync(specPath)) {
|
|
125
|
+
throw new Error(`OpenAPI spec not found: ${specPath}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const text = fs.readFileSync(specPath, 'utf8')
|
|
129
|
+
let data
|
|
130
|
+
try {
|
|
131
|
+
data = yaml.load(text)
|
|
132
|
+
} catch {
|
|
133
|
+
data = JSON.parse(text)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!data || typeof data !== 'object') {
|
|
137
|
+
throw new Error(`Invalid OpenAPI spec: ${specPath}`)
|
|
138
|
+
}
|
|
139
|
+
if (!data.openapi) {
|
|
140
|
+
throw new Error(`Missing 'openapi' field: ${specPath}`)
|
|
141
|
+
}
|
|
142
|
+
if (!data.paths || typeof data.paths !== 'object') {
|
|
143
|
+
throw new Error(`Missing 'paths' object: ${specPath}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return data
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runKubb({ input, outputRoot, outputDir, client }) {
|
|
150
|
+
const kubbOutputDir = resolveOutputPath(outputRoot, outputDir, path.join('kubb'))
|
|
151
|
+
fs.mkdirSync(kubbOutputDir, { recursive: true })
|
|
152
|
+
|
|
153
|
+
const tempConfigPath = path.join(os.tmpdir(), `.openapi-multitarget-codegen-kubb-${Date.now()}.config.mjs`)
|
|
154
|
+
const configSource = `
|
|
155
|
+
import { createRequire } from 'node:module'
|
|
156
|
+
|
|
157
|
+
const require = createRequire(${JSON.stringify(path.join(PACKAGE_ROOT, 'package.json'))})
|
|
158
|
+
const { defineConfig } = require('@kubb/core')
|
|
159
|
+
const { pluginOas } = require('@kubb/plugin-oas')
|
|
160
|
+
const { pluginTs } = require('@kubb/plugin-ts')
|
|
161
|
+
const { pluginClient } = require('@kubb/plugin-client')
|
|
162
|
+
|
|
163
|
+
export default defineConfig({
|
|
164
|
+
root: '.',
|
|
165
|
+
input: {
|
|
166
|
+
path: ${JSON.stringify(input)},
|
|
167
|
+
},
|
|
168
|
+
output: {
|
|
169
|
+
path: ${JSON.stringify(kubbOutputDir)},
|
|
170
|
+
clean: true,
|
|
171
|
+
},
|
|
172
|
+
plugins: [
|
|
173
|
+
pluginOas(),
|
|
174
|
+
pluginTs({
|
|
175
|
+
output: {
|
|
176
|
+
path: './types',
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
pluginClient({
|
|
180
|
+
output: {
|
|
181
|
+
path: './client',
|
|
182
|
+
},
|
|
183
|
+
client: ${JSON.stringify(client)},
|
|
184
|
+
}),
|
|
185
|
+
],
|
|
186
|
+
})
|
|
187
|
+
`
|
|
188
|
+
fs.writeFileSync(tempConfigPath, configSource)
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const result = spawnSync(process.execPath, [resolveBinScript('@kubb/cli'), 'generate', '--config', tempConfigPath], {
|
|
192
|
+
stdio: 'inherit',
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
if (result.status !== 0) {
|
|
196
|
+
throw new Error('Kubb generation failed')
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
cleanupTempFile(tempConfigPath)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.log(`✓ generated kubb output -> ${kubbOutputDir}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function runPython({ input, outputRoot, outputFile, modelType }) {
|
|
206
|
+
ensureCommandAvailable('uvx', 'Python type generation requires uvx. Install uv first, for example: brew install uv')
|
|
207
|
+
|
|
208
|
+
const resolvedOutputFile = resolveOutputPath(outputRoot, outputFile, path.join('python', 'models.py'))
|
|
209
|
+
const pythonOutputDir = path.dirname(resolvedOutputFile)
|
|
210
|
+
fs.mkdirSync(pythonOutputDir, { recursive: true })
|
|
211
|
+
|
|
212
|
+
const result = spawnSync('uvx', [
|
|
213
|
+
'--from', 'datamodel-code-generator',
|
|
214
|
+
'datamodel-codegen',
|
|
215
|
+
'--input', input,
|
|
216
|
+
'--output', resolvedOutputFile,
|
|
217
|
+
'--output-model-type', modelType,
|
|
218
|
+
], {
|
|
219
|
+
stdio: 'inherit',
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
if (result.status !== 0) {
|
|
223
|
+
throw new Error('Python type generation failed')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log(`✓ generated python output -> ${resolvedOutputFile}`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function runLangGraph({ input, outputRoot, outputDir, fileName }) {
|
|
230
|
+
const langgraphOutputDir = resolveOutputPath(outputRoot, outputDir, path.join('langgraph'))
|
|
231
|
+
const outputFile = path.join(langgraphOutputDir, fileName)
|
|
232
|
+
fs.mkdirSync(langgraphOutputDir, { recursive: true })
|
|
233
|
+
|
|
234
|
+
const doc = validateOpenApiFile(input)
|
|
235
|
+
const paths = doc.paths ?? {}
|
|
236
|
+
const components = doc.components ?? {}
|
|
237
|
+
const operations = []
|
|
238
|
+
|
|
239
|
+
for (const [route, methods] of Object.entries(paths)) {
|
|
240
|
+
for (const [method, op] of Object.entries(methods ?? {})) {
|
|
241
|
+
const lower = method.toLowerCase()
|
|
242
|
+
if (!['get', 'post', 'put', 'patch', 'delete'].includes(lower)) continue
|
|
243
|
+
|
|
244
|
+
const operationId = op?.operationId || `${lower}_${route.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')}`
|
|
245
|
+
const parameters = [...(op?.parameters ?? [])].map((param) => simplifyParameter(resolveSchemaObject(param, components), components))
|
|
246
|
+
const requestBodySchema = resolveRequestBodySchema(op?.requestBody, components)
|
|
247
|
+
const requestBody = requestBodySchema ? simplifySchema(requestBodySchema, components) : null
|
|
248
|
+
const responseSummary = summarizePrimaryResponse(op?.responses, components)
|
|
249
|
+
const authRequired = hasSecurityRequirement(op?.security, doc.security)
|
|
250
|
+
|
|
251
|
+
operations.push({
|
|
252
|
+
method: lower,
|
|
253
|
+
route,
|
|
254
|
+
operationId,
|
|
255
|
+
summary: op?.summary || op?.description || `${lower.toUpperCase()} ${route}`,
|
|
256
|
+
description: op?.description || op?.summary || `${lower.toUpperCase()} ${route}`,
|
|
257
|
+
parameters,
|
|
258
|
+
requestBody,
|
|
259
|
+
responseSummary,
|
|
260
|
+
authRequired,
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const operationDefs = toPythonLiteral(operations)
|
|
266
|
+
const sourceNote = path.basename(input)
|
|
267
|
+
|
|
268
|
+
const file = `"""
|
|
269
|
+
AUTO-GENERATED FILE.
|
|
270
|
+
Source: ${sourceNote}
|
|
271
|
+
Purpose: LangGraph/LangChain StructuredTool draft generated from OpenAPI.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
from __future__ import annotations
|
|
275
|
+
|
|
276
|
+
import json
|
|
277
|
+
from typing import Any, Optional
|
|
278
|
+
|
|
279
|
+
import httpx
|
|
280
|
+
from langchain_core.tools import StructuredTool
|
|
281
|
+
from pydantic import BaseModel, Field, create_model
|
|
282
|
+
|
|
283
|
+
OPENAPI_TYPE_MAP: dict[str, Any] = {
|
|
284
|
+
"string": str,
|
|
285
|
+
"integer": int,
|
|
286
|
+
"number": float,
|
|
287
|
+
"boolean": bool,
|
|
288
|
+
"object": dict,
|
|
289
|
+
"array": list,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
OPERATION_DEFS = ${operationDefs}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _python_type(openapi_type: str | None) -> Any:
|
|
296
|
+
return OPENAPI_TYPE_MAP.get(openapi_type or "string", str)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _build_args_schema(operation_id: str, parameters: list[dict[str, Any]], body_schema: dict[str, Any] | None) -> type[BaseModel] | None:
|
|
300
|
+
fields: dict[str, Any] = {}
|
|
301
|
+
|
|
302
|
+
for param in parameters:
|
|
303
|
+
name = param.get("name")
|
|
304
|
+
if not name:
|
|
305
|
+
continue
|
|
306
|
+
py_type = _python_type(param.get("type"))
|
|
307
|
+
param_location = param.get("in") or "parameter"
|
|
308
|
+
description = param.get("description") or f"{param_location} parameter: {name}"
|
|
309
|
+
required = bool(param.get("required", False))
|
|
310
|
+
if required:
|
|
311
|
+
fields[name] = (py_type, Field(description=description))
|
|
312
|
+
else:
|
|
313
|
+
fields[name] = (Optional[py_type], Field(default=None, description=description))
|
|
314
|
+
|
|
315
|
+
if body_schema:
|
|
316
|
+
properties = body_schema.get("properties", {}) or {}
|
|
317
|
+
required_props = set(body_schema.get("required", []) or [])
|
|
318
|
+
for prop_name, prop_schema in properties.items():
|
|
319
|
+
py_type = _python_type(prop_schema.get("type"))
|
|
320
|
+
description = prop_schema.get("description") or f"request body field: {prop_name}"
|
|
321
|
+
if prop_name in required_props:
|
|
322
|
+
fields[prop_name] = (py_type, Field(description=description))
|
|
323
|
+
else:
|
|
324
|
+
fields[prop_name] = (Optional[py_type], Field(default=None, description=description))
|
|
325
|
+
|
|
326
|
+
if not fields:
|
|
327
|
+
return create_model(f"{operation_id}_args")
|
|
328
|
+
|
|
329
|
+
return create_model(f"{operation_id}_args", **fields)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _summarize_args(parameters: list[dict[str, Any]], body_schema: dict[str, Any] | None) -> list[str]:
|
|
333
|
+
parts: list[str] = []
|
|
334
|
+
path_names = [param["name"] for param in parameters if param.get("in") == "path" and param.get("name")]
|
|
335
|
+
query_names = [param["name"] for param in parameters if param.get("in") == "query" and param.get("name")]
|
|
336
|
+
body_names = list((body_schema or {}).get("properties", {}).keys())
|
|
337
|
+
|
|
338
|
+
if path_names:
|
|
339
|
+
parts.append("path: " + ", ".join(path_names))
|
|
340
|
+
if query_names:
|
|
341
|
+
parts.append("query: " + ", ".join(query_names))
|
|
342
|
+
if body_names:
|
|
343
|
+
parts.append("body: " + ", ".join(body_names))
|
|
344
|
+
return parts
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _operation_kind(method: str) -> str:
|
|
348
|
+
upper = method.upper()
|
|
349
|
+
if upper == "GET":
|
|
350
|
+
return "Read operation"
|
|
351
|
+
if upper == "POST":
|
|
352
|
+
return "Create operation"
|
|
353
|
+
if upper in {"PUT", "PATCH"}:
|
|
354
|
+
return "Update operation"
|
|
355
|
+
if upper == "DELETE":
|
|
356
|
+
return "Delete operation"
|
|
357
|
+
return "HTTP operation"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _summarize_response(response_summary: dict[str, Any] | None) -> str:
|
|
362
|
+
if not response_summary:
|
|
363
|
+
return ""
|
|
364
|
+
status = response_summary.get("status")
|
|
365
|
+
schema_type = response_summary.get("type")
|
|
366
|
+
description = response_summary.get("description") or ""
|
|
367
|
+
parts = []
|
|
368
|
+
if status:
|
|
369
|
+
parts.append(f"Returns status {status}")
|
|
370
|
+
else:
|
|
371
|
+
parts.append("Returns a response")
|
|
372
|
+
if schema_type:
|
|
373
|
+
parts[-1] += f" with {schema_type} payload"
|
|
374
|
+
if description:
|
|
375
|
+
parts[-1] += f" ({description})"
|
|
376
|
+
return parts[0] + "."
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _clean_sentence(text: str) -> str:
|
|
381
|
+
return text.strip().rstrip(".?!")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _build_tool_description(defn: dict[str, Any]) -> str:
|
|
386
|
+
operation_id = defn["operationId"]
|
|
387
|
+
route = defn["route"]
|
|
388
|
+
method = defn["method"].upper()
|
|
389
|
+
summary = _clean_sentence(defn.get("description") or defn.get("summary") or operation_id)
|
|
390
|
+
parameters = list(defn.get("parameters") or [])
|
|
391
|
+
body_schema = defn.get("requestBody")
|
|
392
|
+
response_summary = defn.get("responseSummary")
|
|
393
|
+
auth_required = bool(defn.get("authRequired"))
|
|
394
|
+
arg_summary = _summarize_args(parameters, body_schema)
|
|
395
|
+
parts = [f"{method} {route} - {summary}.", _operation_kind(method) + "."]
|
|
396
|
+
if arg_summary:
|
|
397
|
+
parts.append(f"Inputs: {'; '.join(arg_summary)}.")
|
|
398
|
+
if response_summary:
|
|
399
|
+
parts.append(_summarize_response(response_summary))
|
|
400
|
+
if auth_required:
|
|
401
|
+
parts.append("Authentication required.")
|
|
402
|
+
return " ".join(part.strip() for part in parts if part).strip()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _make_tool(defn: dict[str, Any], base_url: str) -> StructuredTool:
|
|
406
|
+
operation_id = defn["operationId"]
|
|
407
|
+
description = _build_tool_description(defn)
|
|
408
|
+
route = defn["route"]
|
|
409
|
+
method = defn["method"]
|
|
410
|
+
parameters = list(defn.get("parameters") or [])
|
|
411
|
+
body_schema = defn.get("requestBody")
|
|
412
|
+
|
|
413
|
+
def _render_url(kwargs: dict[str, Any]) -> str:
|
|
414
|
+
rendered_path = route
|
|
415
|
+
for param in parameters:
|
|
416
|
+
if param.get("in") != "path":
|
|
417
|
+
continue
|
|
418
|
+
name = param.get("name")
|
|
419
|
+
value = kwargs.get(name)
|
|
420
|
+
if name and value is not None:
|
|
421
|
+
rendered_path = rendered_path.replace("{" + name + "}", str(value))
|
|
422
|
+
return base_url.rstrip("/") + rendered_path
|
|
423
|
+
|
|
424
|
+
def _query_params(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
425
|
+
return {
|
|
426
|
+
param["name"]: kwargs.get(param["name"])
|
|
427
|
+
for param in parameters
|
|
428
|
+
if param.get("in") == "query" and kwargs.get(param.get("name")) is not None
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
def _request_body(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
432
|
+
path_names = {param["name"] for param in parameters if param.get("in") == "path" and param.get("name")}
|
|
433
|
+
query_names = {param["name"] for param in parameters if param.get("in") == "query" and param.get("name")}
|
|
434
|
+
return {
|
|
435
|
+
key: value
|
|
436
|
+
for key, value in kwargs.items()
|
|
437
|
+
if value is not None and key not in path_names and key not in query_names
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
def _call_sync(**kwargs: Any) -> str:
|
|
441
|
+
url = _render_url(kwargs)
|
|
442
|
+
try:
|
|
443
|
+
with httpx.Client(timeout=30.0) as client:
|
|
444
|
+
if method == "get":
|
|
445
|
+
response = client.get(url, params=_query_params(kwargs))
|
|
446
|
+
elif method == "post":
|
|
447
|
+
response = client.post(url, params=_query_params(kwargs), json=_request_body(kwargs))
|
|
448
|
+
elif method == "put":
|
|
449
|
+
response = client.put(url, params=_query_params(kwargs), json=_request_body(kwargs))
|
|
450
|
+
elif method == "patch":
|
|
451
|
+
response = client.patch(url, params=_query_params(kwargs), json=_request_body(kwargs))
|
|
452
|
+
elif method == "delete":
|
|
453
|
+
response = client.delete(url, params=_query_params(kwargs))
|
|
454
|
+
else:
|
|
455
|
+
return f"Unsupported HTTP method: {method}"
|
|
456
|
+
|
|
457
|
+
if response.is_success:
|
|
458
|
+
return _format_response(response)
|
|
459
|
+
return f"Request failed with status {response.status_code}: {response.text}"
|
|
460
|
+
except Exception as exc: # noqa: BLE001
|
|
461
|
+
return f"Tool call error: {exc}"
|
|
462
|
+
|
|
463
|
+
async def _call_async(**kwargs: Any) -> str:
|
|
464
|
+
url = _render_url(kwargs)
|
|
465
|
+
try:
|
|
466
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
467
|
+
if method == "get":
|
|
468
|
+
response = await client.get(url, params=_query_params(kwargs))
|
|
469
|
+
elif method == "post":
|
|
470
|
+
response = await client.post(url, params=_query_params(kwargs), json=_request_body(kwargs))
|
|
471
|
+
elif method == "put":
|
|
472
|
+
response = await client.put(url, params=_query_params(kwargs), json=_request_body(kwargs))
|
|
473
|
+
elif method == "patch":
|
|
474
|
+
response = await client.patch(url, params=_query_params(kwargs), json=_request_body(kwargs))
|
|
475
|
+
elif method == "delete":
|
|
476
|
+
response = await client.delete(url, params=_query_params(kwargs))
|
|
477
|
+
else:
|
|
478
|
+
return f"Unsupported HTTP method: {method}"
|
|
479
|
+
|
|
480
|
+
if response.is_success:
|
|
481
|
+
return _format_response(response)
|
|
482
|
+
return f"Request failed with status {response.status_code}: {response.text}"
|
|
483
|
+
except Exception as exc: # noqa: BLE001
|
|
484
|
+
return f"Tool call error: {exc}"
|
|
485
|
+
|
|
486
|
+
_call_sync.__doc__ = description
|
|
487
|
+
_call_async.__doc__ = description
|
|
488
|
+
|
|
489
|
+
args_schema = _build_args_schema(operation_id, parameters, body_schema)
|
|
490
|
+
return StructuredTool.from_function(
|
|
491
|
+
func=_call_sync,
|
|
492
|
+
coroutine=_call_async,
|
|
493
|
+
name=operation_id,
|
|
494
|
+
description=description,
|
|
495
|
+
return_direct=False,
|
|
496
|
+
args_schema=args_schema,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _format_response(response: httpx.Response) -> str:
|
|
501
|
+
content_type = response.headers.get("content-type", "")
|
|
502
|
+
if "application/json" in content_type:
|
|
503
|
+
try:
|
|
504
|
+
return json.dumps(response.json(), ensure_ascii=False)
|
|
505
|
+
except Exception: # noqa: BLE001
|
|
506
|
+
return response.text
|
|
507
|
+
return response.text
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def get_generated_tools(base_url: str) -> list[StructuredTool]:
|
|
511
|
+
return [_make_tool(defn, base_url=base_url) for defn in OPERATION_DEFS]
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def list_generated_tool_defs() -> list[dict[str, Any]]:
|
|
515
|
+
return [
|
|
516
|
+
{
|
|
517
|
+
"name": defn["operationId"],
|
|
518
|
+
"method": defn["method"].upper(),
|
|
519
|
+
"path": defn["route"],
|
|
520
|
+
"description": _build_tool_description(defn),
|
|
521
|
+
}
|
|
522
|
+
for defn in OPERATION_DEFS
|
|
523
|
+
]
|
|
524
|
+
`
|
|
525
|
+
|
|
526
|
+
fs.writeFileSync(outputFile, file)
|
|
527
|
+
console.log(`✓ generated langgraph output -> ${outputFile}`)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function resolveBinScript(moduleName) {
|
|
531
|
+
const packageJsonPath = require.resolve(`${moduleName}/package.json`)
|
|
532
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
|
533
|
+
const binField = packageJson.bin
|
|
534
|
+
const relativeBinPath = typeof binField === 'string'
|
|
535
|
+
? binField
|
|
536
|
+
: typeof binField === 'object' && binField !== null
|
|
537
|
+
? Object.values(binField)[0]
|
|
538
|
+
: null
|
|
539
|
+
|
|
540
|
+
if (!relativeBinPath) {
|
|
541
|
+
throw new Error(`Unable to resolve bin entry for ${moduleName}`)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return path.join(path.dirname(packageJsonPath), relativeBinPath)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function cleanupTempFile(filePath) {
|
|
548
|
+
try {
|
|
549
|
+
fs.unlinkSync(filePath)
|
|
550
|
+
} catch {
|
|
551
|
+
// ignore cleanup errors
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function ensureCommandAvailable(command, installHint) {
|
|
556
|
+
const result = spawnSync(command, ['--version'], { stdio: 'ignore' })
|
|
557
|
+
if (result.error?.code === 'ENOENT') {
|
|
558
|
+
throw new Error(installHint)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function resolveOutputPath(outputRoot, candidatePath, defaultRelativePath) {
|
|
563
|
+
if (!candidatePath) {
|
|
564
|
+
return path.join(outputRoot, defaultRelativePath)
|
|
565
|
+
}
|
|
566
|
+
return path.isAbsolute(candidatePath)
|
|
567
|
+
? path.resolve(candidatePath)
|
|
568
|
+
: path.resolve(outputRoot, candidatePath)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function toPythonLiteral(value, indent = 0) {
|
|
572
|
+
const spaces = ' '.repeat(indent)
|
|
573
|
+
const childSpaces = ' '.repeat(indent + 1)
|
|
574
|
+
|
|
575
|
+
if (value === null) return 'None'
|
|
576
|
+
if (value === true) return 'True'
|
|
577
|
+
if (value === false) return 'False'
|
|
578
|
+
if (typeof value === 'string') return JSON.stringify(value)
|
|
579
|
+
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'None'
|
|
580
|
+
if (Array.isArray(value)) {
|
|
581
|
+
if (value.length === 0) return '[]'
|
|
582
|
+
return `[\n${value.map((item) => `${childSpaces}${toPythonLiteral(item, indent + 1)}`).join(',\n')}\n${spaces}]`
|
|
583
|
+
}
|
|
584
|
+
if (value && typeof value === 'object') {
|
|
585
|
+
const entries = Object.entries(value)
|
|
586
|
+
if (entries.length === 0) return '{}'
|
|
587
|
+
return `{\n${entries.map(([key, item]) => `${childSpaces}${JSON.stringify(key)}: ${toPythonLiteral(item, indent + 1)}`).join(',\n')}\n${spaces}}`
|
|
588
|
+
}
|
|
589
|
+
return 'None'
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function guardCleanPath(outputPath) {
|
|
593
|
+
const resolved = path.resolve(outputPath)
|
|
594
|
+
const currentWorkingDirectory = process.cwd()
|
|
595
|
+
const homeDirectory = process.env.HOME ? path.resolve(process.env.HOME) : null
|
|
596
|
+
|
|
597
|
+
if (resolved === path.parse(resolved).root) {
|
|
598
|
+
throw new Error('--clean cannot target the filesystem root')
|
|
599
|
+
}
|
|
600
|
+
if (resolved === currentWorkingDirectory) {
|
|
601
|
+
throw new Error('--clean cannot target the current working directory directly')
|
|
602
|
+
}
|
|
603
|
+
if (homeDirectory && resolved === homeDirectory) {
|
|
604
|
+
throw new Error('--clean cannot target the home directory')
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function summarizePrimaryResponse(responses, components) {
|
|
609
|
+
if (!responses || typeof responses !== 'object') return null
|
|
610
|
+
|
|
611
|
+
const preferredStatuses = ['200', '201', '202', '204', 'default']
|
|
612
|
+
const responseEntries = Object.entries(responses)
|
|
613
|
+
const chosenEntry = preferredStatuses
|
|
614
|
+
.map((status) => responseEntries.find(([code]) => code === status))
|
|
615
|
+
.find(Boolean) ?? responseEntries[0]
|
|
616
|
+
|
|
617
|
+
if (!chosenEntry) return null
|
|
618
|
+
|
|
619
|
+
const [status, response] = chosenEntry
|
|
620
|
+
const responseObj = resolveSchemaObject(response, components) ?? response
|
|
621
|
+
const content = responseObj?.content ?? {}
|
|
622
|
+
const schema = content['application/json']?.schema
|
|
623
|
+
?? content['application/*+json']?.schema
|
|
624
|
+
?? content['*/*']?.schema
|
|
625
|
+
const schemaObj = schema ? resolveSchemaObject(schema, components) : null
|
|
626
|
+
const schemaType = schemaObj ? (schemaObj.type ?? inferSchemaType(schemaObj)) : null
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
status,
|
|
630
|
+
description: responseObj?.description || '',
|
|
631
|
+
type: schemaType,
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function hasSecurityRequirement(operationSecurity, globalSecurity) {
|
|
636
|
+
if (Array.isArray(operationSecurity)) {
|
|
637
|
+
return operationSecurity.length > 0
|
|
638
|
+
}
|
|
639
|
+
if (Array.isArray(globalSecurity)) {
|
|
640
|
+
return globalSecurity.length > 0
|
|
641
|
+
}
|
|
642
|
+
return false
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function resolveRequestBodySchema(requestBody, components) {
|
|
646
|
+
if (!requestBody) return null
|
|
647
|
+
const bodyObj = resolveSchemaObject(requestBody, components)
|
|
648
|
+
const schema = bodyObj?.content?.['application/json']?.schema
|
|
649
|
+
?? bodyObj?.content?.['application/*+json']?.schema
|
|
650
|
+
?? bodyObj?.content?.['*/*']?.schema
|
|
651
|
+
if (!schema) return null
|
|
652
|
+
return resolveSchemaObject(schema, components)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function resolveSchemaObject(obj, components) {
|
|
656
|
+
if (!obj || typeof obj !== 'object') return obj
|
|
657
|
+
if (obj.$ref) {
|
|
658
|
+
const ref = obj.$ref
|
|
659
|
+
if (ref.startsWith('#/components/schemas/')) {
|
|
660
|
+
const key = ref.split('/').pop()
|
|
661
|
+
return resolveSchemaObject(components?.schemas?.[key], components)
|
|
662
|
+
}
|
|
663
|
+
if (ref.startsWith('#/components/requestBodies/')) {
|
|
664
|
+
const key = ref.split('/').pop()
|
|
665
|
+
return resolveSchemaObject(components?.requestBodies?.[key], components)
|
|
666
|
+
}
|
|
667
|
+
if (ref.startsWith('#/components/parameters/')) {
|
|
668
|
+
const key = ref.split('/').pop()
|
|
669
|
+
return resolveSchemaObject(components?.parameters?.[key], components)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return obj
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function simplifyParameter(param, components) {
|
|
676
|
+
const p = resolveSchemaObject(param, components) ?? {}
|
|
677
|
+
const schema = resolveSchemaObject(p.schema ?? {}, components) ?? {}
|
|
678
|
+
return {
|
|
679
|
+
name: p.name,
|
|
680
|
+
in: p.in,
|
|
681
|
+
required: Boolean(p.required),
|
|
682
|
+
description: p.description ?? schema.description ?? '',
|
|
683
|
+
type: schema.type ?? inferSchemaType(schema),
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function simplifySchema(schema, components) {
|
|
688
|
+
const resolved = resolveSchemaObject(schema, components) ?? {}
|
|
689
|
+
const properties = {}
|
|
690
|
+
for (const [name, propSchema] of Object.entries(resolved.properties ?? {})) {
|
|
691
|
+
const prop = resolveSchemaObject(propSchema, components) ?? {}
|
|
692
|
+
properties[name] = {
|
|
693
|
+
type: prop.type ?? inferSchemaType(prop),
|
|
694
|
+
description: prop.description ?? '',
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
type: resolved.type ?? 'object',
|
|
699
|
+
required: resolved.required ?? [],
|
|
700
|
+
properties,
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function inferSchemaType(schema) {
|
|
705
|
+
if (!schema || typeof schema !== 'object') return 'string'
|
|
706
|
+
if (schema.type) return schema.type
|
|
707
|
+
if (schema.properties) return 'object'
|
|
708
|
+
if (schema.items) return 'array'
|
|
709
|
+
return 'string'
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function printHelp() {
|
|
713
|
+
console.log(`openapi-multitarget-codegen
|
|
714
|
+
|
|
715
|
+
Usage:
|
|
716
|
+
openapi-multitarget-codegen generate --input <spec> --output <dir> --target <kubb|python|langgraph> [--target ...]
|
|
717
|
+
openapi-multitarget-codegen check-spec --input <spec>
|
|
718
|
+
|
|
719
|
+
Options:
|
|
720
|
+
--input <path> OpenAPI file path (.yaml or .json)
|
|
721
|
+
--output <dir> Output root directory
|
|
722
|
+
--target <name> Generation target (repeatable)
|
|
723
|
+
--python-model-type <type> Python output model type (default: pydantic_v2.BaseModel)
|
|
724
|
+
--kubb-output-dir <path> Kubb output directory (default: <output>/kubb)
|
|
725
|
+
--python-output-file <path> Python output file (default: <output>/python/models.py)
|
|
726
|
+
--langgraph-output-dir <path> LangGraph output directory (default: <output>/langgraph)
|
|
727
|
+
--langgraph-file <name> LangGraph output filename (default: langgraph_tools.py)
|
|
728
|
+
--kubb-client <client> Kubb client type (default: fetch)
|
|
729
|
+
--clean Remove output root before generation
|
|
730
|
+
--help Show help
|
|
731
|
+
`)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
main()
|
|
735
|
+
|