docfy-core 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 +21 -0
- package/dist/document-model/cap-depth.d.ts +17 -0
- package/dist/document-model/cap-depth.js +41 -0
- package/dist/document-model/example.d.ts +22 -0
- package/dist/document-model/example.js +164 -0
- package/dist/document-model/normalize.d.ts +13 -0
- package/dist/document-model/normalize.js +146 -0
- package/dist/document-model/types.d.ts +49 -0
- package/dist/document-model/types.js +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/transformers/copy-for-ai.d.ts +11 -0
- package/dist/transformers/copy-for-ai.js +138 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# docfy-core
|
|
2
|
+
|
|
3
|
+
Document Model puro (normalização de spec OpenAPI + gerador de exemplos +
|
|
4
|
+
transformer "Copy for AI") extraído do `docfy-ui`, sem dependências de
|
|
5
|
+
React/DOM. Consumido por `docfy-ui` e `docfy-mcp`.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { normalizeDocument, operationToAiText } from 'docfy-core';
|
|
11
|
+
|
|
12
|
+
const document = await normalizeDocument(rawOpenApiSpec);
|
|
13
|
+
const endpoint = document.tagGroups[0].endpoints[0];
|
|
14
|
+
const aiText = operationToAiText(endpoint);
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Scripts
|
|
18
|
+
|
|
19
|
+
- `npm run build` — compila para `dist/` (tipos + JS)
|
|
20
|
+
- `npm test` — roda a suíte (vitest)
|
|
21
|
+
- `npm run typecheck` — `tsc --noEmit`
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface CapDepthOptions {
|
|
2
|
+
maxDepth?: number;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Walks a dereferenced OpenAPI schema (or any object graph) and produces a
|
|
6
|
+
* JSON-safe copy: cycles become `"[Circular]"` and anything past `maxDepth`
|
|
7
|
+
* becomes `"[Object]"`/`"[Array]"`.
|
|
8
|
+
*
|
|
9
|
+
* Needed because `SwaggerParser.dereference()` resolves `$ref` into real
|
|
10
|
+
* object identity — a recursive DTO (`User.parent: User`) becomes an actual
|
|
11
|
+
* cycle, which `JSON.stringify` cannot serialize. Cycle detection uses the
|
|
12
|
+
* current ancestor chain, not a global "seen" set: the same dereferenced
|
|
13
|
+
* object can legitimately appear in multiple sibling branches (e.g.
|
|
14
|
+
* `createdBy: User` and `updatedBy: User`) without being circular — only
|
|
15
|
+
* an object appearing in its own chain of parents is a real cycle.
|
|
16
|
+
*/
|
|
17
|
+
export declare function capDepth(value: unknown, options?: CapDepthOptions): unknown;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const DEFAULT_MAX_DEPTH = 50;
|
|
2
|
+
/**
|
|
3
|
+
* Walks a dereferenced OpenAPI schema (or any object graph) and produces a
|
|
4
|
+
* JSON-safe copy: cycles become `"[Circular]"` and anything past `maxDepth`
|
|
5
|
+
* becomes `"[Object]"`/`"[Array]"`.
|
|
6
|
+
*
|
|
7
|
+
* Needed because `SwaggerParser.dereference()` resolves `$ref` into real
|
|
8
|
+
* object identity — a recursive DTO (`User.parent: User`) becomes an actual
|
|
9
|
+
* cycle, which `JSON.stringify` cannot serialize. Cycle detection uses the
|
|
10
|
+
* current ancestor chain, not a global "seen" set: the same dereferenced
|
|
11
|
+
* object can legitimately appear in multiple sibling branches (e.g.
|
|
12
|
+
* `createdBy: User` and `updatedBy: User`) without being circular — only
|
|
13
|
+
* an object appearing in its own chain of parents is a real cycle.
|
|
14
|
+
*/
|
|
15
|
+
export function capDepth(value, options = {}) {
|
|
16
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
17
|
+
const ancestors = new Set();
|
|
18
|
+
function walk(node, depth) {
|
|
19
|
+
if (node === null || typeof node !== 'object')
|
|
20
|
+
return node;
|
|
21
|
+
if (ancestors.has(node))
|
|
22
|
+
return '[Circular]';
|
|
23
|
+
if (depth >= maxDepth)
|
|
24
|
+
return Array.isArray(node) ? '[Array]' : '[Object]';
|
|
25
|
+
ancestors.add(node);
|
|
26
|
+
try {
|
|
27
|
+
if (Array.isArray(node)) {
|
|
28
|
+
return node.map((item) => walk(item, depth + 1));
|
|
29
|
+
}
|
|
30
|
+
const result = {};
|
|
31
|
+
for (const [key, val] of Object.entries(node)) {
|
|
32
|
+
result[key] = walk(val, depth + 1);
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
ancestors.delete(node);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return walk(value, 0);
|
|
41
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { JSONSchemaLike, ResponseInfo } from './types.js';
|
|
2
|
+
export declare const STATUS_TEXT: Record<string, string>;
|
|
3
|
+
/** Resolves `allOf`/`oneOf`/`anyOf` to a single concrete schema. Returns the variant count when a `oneOf`/`anyOf` union was found. */
|
|
4
|
+
export declare function resolveUnion(schema: JSONSchemaLike | undefined): {
|
|
5
|
+
schema: JSONSchemaLike | undefined;
|
|
6
|
+
unionSize?: number;
|
|
7
|
+
};
|
|
8
|
+
/** Text used wherever a schema cycle is detected, instead of expanding it again. */
|
|
9
|
+
export declare function circularMarker(schema: JSONSchemaLike): string;
|
|
10
|
+
export interface SchemaExampleResult {
|
|
11
|
+
/** The flattened example, JSON.stringify-safe (cycles already capped). */
|
|
12
|
+
example: unknown;
|
|
13
|
+
json: string;
|
|
14
|
+
unionSizes: number[];
|
|
15
|
+
}
|
|
16
|
+
export declare function buildSchemaExample(schema: JSONSchemaLike | undefined): SchemaExampleResult | undefined;
|
|
17
|
+
export declare function withUnionNotes(body: string, unionSizes: number[]): string;
|
|
18
|
+
/**
|
|
19
|
+
* Primary 2xx response: smallest numeric 2xx code declared. Non-numeric
|
|
20
|
+
* statuses (e.g. "default") are not considered.
|
|
21
|
+
*/
|
|
22
|
+
export declare function pickPrimarySuccessResponse(responses: ResponseInfo[]): ResponseInfo | undefined;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { capDepth } from './cap-depth.js';
|
|
2
|
+
export const STATUS_TEXT = {
|
|
3
|
+
'400': 'Bad Request',
|
|
4
|
+
'401': 'Unauthorized',
|
|
5
|
+
'403': 'Forbidden',
|
|
6
|
+
'404': 'Not Found',
|
|
7
|
+
'405': 'Method Not Allowed',
|
|
8
|
+
'409': 'Conflict',
|
|
9
|
+
'422': 'Unprocessable Entity',
|
|
10
|
+
'429': 'Too Many Requests',
|
|
11
|
+
'500': 'Internal Server Error',
|
|
12
|
+
'502': 'Bad Gateway',
|
|
13
|
+
'503': 'Service Unavailable',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Caches `schema` (with `allOf`) -> merged result by identity. Without this,
|
|
17
|
+
* every visit to the same `$ref`'d schema (e.g. a recursive `User.manager:
|
|
18
|
+
* User` where `User` itself uses `allOf`) would produce a freshly-allocated
|
|
19
|
+
* merged object, defeating the identity-based cycle detection in
|
|
20
|
+
* `flattenSchema`/`buildNode` below — they track "have I seen this object
|
|
21
|
+
* before" via a `Set`, which only works if the same input always resolves
|
|
22
|
+
* to the same output object.
|
|
23
|
+
*/
|
|
24
|
+
const mergeCache = new WeakMap();
|
|
25
|
+
/**
|
|
26
|
+
* Shallow-merges an `allOf` composition into a single schema so downstream
|
|
27
|
+
* type/property resolution sees one object instead of an opaque array.
|
|
28
|
+
* `$ref`s are already dereferenced by `normalize.ts` by the time this runs,
|
|
29
|
+
* so each `allOf` entry is a real schema object, not a `$ref` string.
|
|
30
|
+
* Recurses so nested `allOf` (e.g. a `$ref`'d base schema that itself uses
|
|
31
|
+
* `allOf`) flattens fully; `oneOf`/`anyOf` inside a branch are left alone
|
|
32
|
+
* for `resolveUnion` to handle afterward.
|
|
33
|
+
*/
|
|
34
|
+
function mergeAllOf(schema) {
|
|
35
|
+
const branches = schema.allOf;
|
|
36
|
+
if (!branches || branches.length === 0)
|
|
37
|
+
return schema;
|
|
38
|
+
const cached = mergeCache.get(schema);
|
|
39
|
+
if (cached)
|
|
40
|
+
return cached;
|
|
41
|
+
const merged = { ...schema };
|
|
42
|
+
mergeCache.set(schema, merged);
|
|
43
|
+
delete merged.allOf;
|
|
44
|
+
let properties = { ...merged.properties };
|
|
45
|
+
let required = [...(merged.required ?? [])];
|
|
46
|
+
for (const branch of branches) {
|
|
47
|
+
const resolvedBranch = mergeAllOf(branch);
|
|
48
|
+
if (resolvedBranch.type && !merged.type)
|
|
49
|
+
merged.type = resolvedBranch.type;
|
|
50
|
+
if (resolvedBranch.properties) {
|
|
51
|
+
properties = { ...properties, ...resolvedBranch.properties };
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(resolvedBranch.required)) {
|
|
54
|
+
required = [...required, ...resolvedBranch.required];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (Object.keys(properties).length > 0) {
|
|
58
|
+
merged.properties = properties;
|
|
59
|
+
if (!merged.type)
|
|
60
|
+
merged.type = 'object';
|
|
61
|
+
}
|
|
62
|
+
if (required.length > 0)
|
|
63
|
+
merged.required = [...new Set(required)];
|
|
64
|
+
return merged;
|
|
65
|
+
}
|
|
66
|
+
/** Resolves `allOf`/`oneOf`/`anyOf` to a single concrete schema. Returns the variant count when a `oneOf`/`anyOf` union was found. */
|
|
67
|
+
export function resolveUnion(schema) {
|
|
68
|
+
if (!schema)
|
|
69
|
+
return { schema: undefined };
|
|
70
|
+
const flattened = mergeAllOf(schema);
|
|
71
|
+
const union = flattened.oneOf ?? flattened.anyOf;
|
|
72
|
+
if (union && union.length > 0) {
|
|
73
|
+
// The chosen variant (e.g. `DigitalProduct`) may itself be an `allOf`
|
|
74
|
+
// composition — merge it too, otherwise it surfaces with no `type`/
|
|
75
|
+
// `properties` and falls back to the literal string "object".
|
|
76
|
+
return { schema: mergeAllOf(union[0]), unionSize: union.length };
|
|
77
|
+
}
|
|
78
|
+
return { schema: flattened };
|
|
79
|
+
}
|
|
80
|
+
/** Text used wherever a schema cycle is detected, instead of expanding it again. */
|
|
81
|
+
export function circularMarker(schema) {
|
|
82
|
+
const title = typeof schema.title === 'string' ? schema.title : undefined;
|
|
83
|
+
return title ? `(circular reference to ${title})` : '(circular reference)';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Hard backstop against pathologically deep (but non-cyclic) schemas.
|
|
87
|
+
* Real cycles are caught immediately by ancestor tracking below — this
|
|
88
|
+
* only matters for the rare case of genuinely 20+ levels of distinct
|
|
89
|
+
* nesting.
|
|
90
|
+
*/
|
|
91
|
+
const MAX_DEPTH = 20;
|
|
92
|
+
/**
|
|
93
|
+
* Flattens a JSON Schema into an example using TYPE NAMES as values
|
|
94
|
+
* ("name": "string"), never fake data — per the Copy for AI spec, section
|
|
95
|
+
* 3.2 step 3 ("chave: tipo, não valores fake"). Shared between the Copy
|
|
96
|
+
* for AI transformer and the code snippet generators so both render the
|
|
97
|
+
* exact same request/response body shape.
|
|
98
|
+
*
|
|
99
|
+
* `ancestors` tracks resolved schema objects currently on the recursion
|
|
100
|
+
* path (by identity, not a depth counter) — `SwaggerParser.dereference()`
|
|
101
|
+
* turns recursive DTOs (`User.parent: User`) into real object cycles, and
|
|
102
|
+
* a depth cap alone just unrolls the cycle N times before giving up,
|
|
103
|
+
* producing a huge wall of duplicated, useless nesting. Detecting the
|
|
104
|
+
* cycle the first time it repeats produces one concise marker instead.
|
|
105
|
+
*/
|
|
106
|
+
function flattenSchema(schema, depth, unionSizes, ancestors) {
|
|
107
|
+
if (!schema || depth > MAX_DEPTH)
|
|
108
|
+
return 'object';
|
|
109
|
+
const { schema: resolved, unionSize } = resolveUnion(schema);
|
|
110
|
+
if (unionSize)
|
|
111
|
+
unionSizes.push(unionSize);
|
|
112
|
+
if (!resolved)
|
|
113
|
+
return 'object';
|
|
114
|
+
if (ancestors.has(resolved))
|
|
115
|
+
return circularMarker(resolved);
|
|
116
|
+
const rawType = resolved.type;
|
|
117
|
+
const type = Array.isArray(rawType) ? rawType.find((t) => t !== 'null') ?? rawType[0] : rawType;
|
|
118
|
+
if (type === 'object' || resolved.properties) {
|
|
119
|
+
const props = resolved.properties ?? {};
|
|
120
|
+
ancestors.add(resolved);
|
|
121
|
+
const out = {};
|
|
122
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
123
|
+
out[key] = flattenSchema(propSchema, depth + 1, unionSizes, ancestors);
|
|
124
|
+
}
|
|
125
|
+
ancestors.delete(resolved);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
if (type === 'array') {
|
|
129
|
+
ancestors.add(resolved);
|
|
130
|
+
const item = flattenSchema(resolved.items, depth + 1, unionSizes, ancestors);
|
|
131
|
+
ancestors.delete(resolved);
|
|
132
|
+
return [item];
|
|
133
|
+
}
|
|
134
|
+
if (typeof type === 'string')
|
|
135
|
+
return type;
|
|
136
|
+
return 'object';
|
|
137
|
+
}
|
|
138
|
+
export function buildSchemaExample(schema) {
|
|
139
|
+
if (!schema)
|
|
140
|
+
return undefined;
|
|
141
|
+
const unionSizes = [];
|
|
142
|
+
const example = flattenSchema(schema, 0, unionSizes, new Set());
|
|
143
|
+
// Defense in depth: flattenSchema's own cycle detection should make this
|
|
144
|
+
// a no-op in practice, but capDepth stays as a second guard against any
|
|
145
|
+
// structure that's deep without being a detectable identity cycle.
|
|
146
|
+
const safe = capDepth(example, { maxDepth: 12 });
|
|
147
|
+
return { example: safe, json: JSON.stringify(safe, null, 2), unionSizes };
|
|
148
|
+
}
|
|
149
|
+
export function withUnionNotes(body, unionSizes) {
|
|
150
|
+
if (unionSizes.length === 0)
|
|
151
|
+
return body;
|
|
152
|
+
const notes = unionSizes.map((n) => `(one of ${n} possible shapes)`);
|
|
153
|
+
return `${body}\n${notes.join('\n')}`;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Primary 2xx response: smallest numeric 2xx code declared. Non-numeric
|
|
157
|
+
* statuses (e.g. "default") are not considered.
|
|
158
|
+
*/
|
|
159
|
+
export function pickPrimarySuccessResponse(responses) {
|
|
160
|
+
const numeric2xx = responses
|
|
161
|
+
.filter((r) => /^2\d\d$/.test(r.status))
|
|
162
|
+
.sort((a, b) => Number(a.status) - Number(b.status));
|
|
163
|
+
return numeric2xx[0];
|
|
164
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DocumentModel } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes a raw OpenAPI 3.0/3.1 spec (JSON or already-parsed object)
|
|
4
|
+
* into the in-memory Document Model consumed by the sidebar and detail
|
|
5
|
+
* panel. Dereferences all `$ref`s — see parser-spike.spec.ts for why
|
|
6
|
+
* @apidevtools/swagger-parser was chosen over the alternative considered.
|
|
7
|
+
*
|
|
8
|
+
* Note: dereferencing turns recursive schemas into real object cycles.
|
|
9
|
+
* This function does not break on that (cycles only matter at
|
|
10
|
+
* serialization time) — callers that JSON.stringify a schema from this
|
|
11
|
+
* model must run it through `capDepth()` first.
|
|
12
|
+
*/
|
|
13
|
+
export declare function normalizeDocument(rawSpec: unknown): Promise<DocumentModel>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
2
|
+
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
3
|
+
const DEFAULT_TAG = 'Default';
|
|
4
|
+
const JSON_CONTENT_TYPE_PREFERENCE = ['application/json'];
|
|
5
|
+
function pickContent(content) {
|
|
6
|
+
if (!content)
|
|
7
|
+
return undefined;
|
|
8
|
+
const keys = Object.keys(content);
|
|
9
|
+
if (keys.length === 0)
|
|
10
|
+
return undefined;
|
|
11
|
+
const preferred = JSON_CONTENT_TYPE_PREFERENCE.find((ct) => keys.includes(ct));
|
|
12
|
+
const contentType = preferred ?? keys[0];
|
|
13
|
+
return { contentType, schema: content[contentType]?.schema };
|
|
14
|
+
}
|
|
15
|
+
function buildParameters(rawParams) {
|
|
16
|
+
if (!rawParams)
|
|
17
|
+
return [];
|
|
18
|
+
return rawParams.map((p) => ({
|
|
19
|
+
name: p.name,
|
|
20
|
+
in: p.in,
|
|
21
|
+
required: Boolean(p.required),
|
|
22
|
+
schema: p.schema,
|
|
23
|
+
description: p.description,
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
function buildRequestBody(rawRequestBody) {
|
|
27
|
+
if (!rawRequestBody)
|
|
28
|
+
return undefined;
|
|
29
|
+
const picked = pickContent(rawRequestBody.content);
|
|
30
|
+
if (!picked)
|
|
31
|
+
return undefined;
|
|
32
|
+
return {
|
|
33
|
+
required: Boolean(rawRequestBody.required),
|
|
34
|
+
contentType: picked.contentType,
|
|
35
|
+
schema: picked.schema,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function buildResponses(rawResponses) {
|
|
39
|
+
if (!rawResponses)
|
|
40
|
+
return [];
|
|
41
|
+
return Object.entries(rawResponses).map(([status, value]) => {
|
|
42
|
+
const picked = pickContent(value?.content);
|
|
43
|
+
return {
|
|
44
|
+
status,
|
|
45
|
+
description: value?.description,
|
|
46
|
+
contentType: picked?.contentType,
|
|
47
|
+
schema: picked?.schema,
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Returns tag names in the order the sidebar should display them:
|
|
53
|
+
* 1. Tags declared in the top-level `tags` array, in that order.
|
|
54
|
+
* 2. Any tag used by an operation but not declared at top level, in the
|
|
55
|
+
* order it's first encountered while walking `paths`.
|
|
56
|
+
* 3. A synthetic "Default" group, last, for operations with no tags at all.
|
|
57
|
+
*/
|
|
58
|
+
function getOrderedTagNames(spec) {
|
|
59
|
+
const declared = (spec.tags ?? []).map((t) => t.name);
|
|
60
|
+
const seen = new Set(declared);
|
|
61
|
+
const discovered = [];
|
|
62
|
+
let hasUntagged = false;
|
|
63
|
+
for (const pathItem of Object.values(spec.paths ?? {})) {
|
|
64
|
+
for (const method of HTTP_METHODS) {
|
|
65
|
+
const operation = pathItem[method];
|
|
66
|
+
if (!operation)
|
|
67
|
+
continue;
|
|
68
|
+
const tags = operation.tags ?? [];
|
|
69
|
+
if (tags.length === 0) {
|
|
70
|
+
hasUntagged = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const tag of tags) {
|
|
74
|
+
if (!seen.has(tag)) {
|
|
75
|
+
seen.add(tag);
|
|
76
|
+
discovered.push(tag);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const ordered = [...declared, ...discovered];
|
|
82
|
+
if (hasUntagged)
|
|
83
|
+
ordered.push(DEFAULT_TAG);
|
|
84
|
+
return ordered;
|
|
85
|
+
}
|
|
86
|
+
function getTagDescription(spec, tagName) {
|
|
87
|
+
const declared = (spec.tags ?? []).find((t) => t.name === tagName);
|
|
88
|
+
return declared?.description;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Normalizes a raw OpenAPI 3.0/3.1 spec (JSON or already-parsed object)
|
|
92
|
+
* into the in-memory Document Model consumed by the sidebar and detail
|
|
93
|
+
* panel. Dereferences all `$ref`s — see parser-spike.spec.ts for why
|
|
94
|
+
* @apidevtools/swagger-parser was chosen over the alternative considered.
|
|
95
|
+
*
|
|
96
|
+
* Note: dereferencing turns recursive schemas into real object cycles.
|
|
97
|
+
* This function does not break on that (cycles only matter at
|
|
98
|
+
* serialization time) — callers that JSON.stringify a schema from this
|
|
99
|
+
* model must run it through `capDepth()` first.
|
|
100
|
+
*/
|
|
101
|
+
export async function normalizeDocument(rawSpec) {
|
|
102
|
+
const spec = (await SwaggerParser.dereference(rawSpec));
|
|
103
|
+
const endpointsByTag = new Map();
|
|
104
|
+
for (const tagName of getOrderedTagNames(spec)) {
|
|
105
|
+
endpointsByTag.set(tagName, []);
|
|
106
|
+
}
|
|
107
|
+
for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
|
|
108
|
+
for (const method of HTTP_METHODS) {
|
|
109
|
+
const operation = pathItem[method];
|
|
110
|
+
if (!operation)
|
|
111
|
+
continue;
|
|
112
|
+
const tags = operation.tags?.length ? operation.tags : [DEFAULT_TAG];
|
|
113
|
+
const endpoint = {
|
|
114
|
+
method: method.toUpperCase(),
|
|
115
|
+
path,
|
|
116
|
+
operationId: operation.operationId,
|
|
117
|
+
summary: operation.summary,
|
|
118
|
+
description: operation.description,
|
|
119
|
+
tags,
|
|
120
|
+
parameters: buildParameters(operation.parameters),
|
|
121
|
+
requestBody: buildRequestBody(operation.requestBody),
|
|
122
|
+
responses: buildResponses(operation.responses),
|
|
123
|
+
};
|
|
124
|
+
for (const tag of tags) {
|
|
125
|
+
if (!endpointsByTag.has(tag))
|
|
126
|
+
endpointsByTag.set(tag, []);
|
|
127
|
+
endpointsByTag.get(tag).push(endpoint);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const tagGroups = Array.from(endpointsByTag.entries())
|
|
132
|
+
.filter(([, endpoints]) => endpoints.length > 0)
|
|
133
|
+
.map(([name, endpoints]) => ({
|
|
134
|
+
name,
|
|
135
|
+
description: getTagDescription(spec, name),
|
|
136
|
+
endpoints,
|
|
137
|
+
}));
|
|
138
|
+
return {
|
|
139
|
+
info: {
|
|
140
|
+
title: spec.info?.title ?? '',
|
|
141
|
+
version: spec.info?.version ?? '',
|
|
142
|
+
description: spec.info?.description,
|
|
143
|
+
},
|
|
144
|
+
tagGroups,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Loosely-typed JSON Schema node — we don't re-implement a schema type system, just pass it through. */
|
|
2
|
+
export type JSONSchemaLike = Record<string, unknown>;
|
|
3
|
+
export interface ParameterInfo {
|
|
4
|
+
name: string;
|
|
5
|
+
in: 'query' | 'path' | 'header' | 'cookie';
|
|
6
|
+
required: boolean;
|
|
7
|
+
schema: JSONSchemaLike | undefined;
|
|
8
|
+
description: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
export interface RequestBodyInfo {
|
|
11
|
+
required: boolean;
|
|
12
|
+
/** The content type actually selected (application/json preferred, else first available). */
|
|
13
|
+
contentType: string;
|
|
14
|
+
schema: JSONSchemaLike | undefined;
|
|
15
|
+
}
|
|
16
|
+
export interface ResponseInfo {
|
|
17
|
+
/** "200", "404", "default", etc. */
|
|
18
|
+
status: string;
|
|
19
|
+
description: string | undefined;
|
|
20
|
+
contentType: string | undefined;
|
|
21
|
+
schema: JSONSchemaLike | undefined;
|
|
22
|
+
}
|
|
23
|
+
export interface Endpoint {
|
|
24
|
+
method: string;
|
|
25
|
+
path: string;
|
|
26
|
+
operationId: string | undefined;
|
|
27
|
+
summary: string | undefined;
|
|
28
|
+
description: string | undefined;
|
|
29
|
+
tags: string[];
|
|
30
|
+
parameters: ParameterInfo[];
|
|
31
|
+
requestBody: RequestBodyInfo | undefined;
|
|
32
|
+
responses: ResponseInfo[];
|
|
33
|
+
}
|
|
34
|
+
export interface TagGroup {
|
|
35
|
+
name: string;
|
|
36
|
+
description: string | undefined;
|
|
37
|
+
endpoints: Endpoint[];
|
|
38
|
+
}
|
|
39
|
+
export interface DocumentModel {
|
|
40
|
+
info: {
|
|
41
|
+
title: string;
|
|
42
|
+
version: string;
|
|
43
|
+
description: string | undefined;
|
|
44
|
+
};
|
|
45
|
+
/** Tags in declared order (from the top-level `tags` array), then any
|
|
46
|
+
* tags used by operations but not declared, in first-appearance order.
|
|
47
|
+
* Operations with no tags fall into a synthetic "Default" group, last. */
|
|
48
|
+
tagGroups: TagGroup[];
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { normalizeDocument } from './document-model/normalize.js';
|
|
2
|
+
export { STATUS_TEXT, buildSchemaExample, circularMarker, pickPrimarySuccessResponse, resolveUnion, withUnionNotes, } from './document-model/example.js';
|
|
3
|
+
export type { SchemaExampleResult } from './document-model/example.js';
|
|
4
|
+
export { capDepth } from './document-model/cap-depth.js';
|
|
5
|
+
export type { CapDepthOptions } from './document-model/cap-depth.js';
|
|
6
|
+
export { operationToAiText } from './transformers/copy-for-ai.js';
|
|
7
|
+
export type { DocumentModel, Endpoint, JSONSchemaLike, ParameterInfo, RequestBodyInfo, ResponseInfo, TagGroup, } from './document-model/types';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { normalizeDocument } from './document-model/normalize.js';
|
|
2
|
+
export { STATUS_TEXT, buildSchemaExample, circularMarker, pickPrimarySuccessResponse, resolveUnion, withUnionNotes, } from './document-model/example.js';
|
|
3
|
+
export { capDepth } from './document-model/cap-depth.js';
|
|
4
|
+
export { operationToAiText } from './transformers/copy-for-ai.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Endpoint } from '../document-model/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Transforms a normalized Endpoint into the plain-text, LLM-ready
|
|
4
|
+
* representation behind the "Copy for AI" button. Pure function, no I/O —
|
|
5
|
+
* runs entirely client-side in well under 5ms.
|
|
6
|
+
*
|
|
7
|
+
* Section order: Endpoint → Purpose → Request → Parameters → Validation →
|
|
8
|
+
* Success Response → Error Responses. Empty sections are omitted, and
|
|
9
|
+
* sections are separated by a blank line for scannability.
|
|
10
|
+
*/
|
|
11
|
+
export declare function operationToAiText(endpoint: Endpoint): string;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { STATUS_TEXT, buildSchemaExample, pickPrimarySuccessResponse, resolveUnion, withUnionNotes, } from '../document-model/example.js';
|
|
2
|
+
function truncateToSentences(text, maxSentences) {
|
|
3
|
+
const sentences = text.trim().split(/(?<=[.!?])\s+/).filter(Boolean);
|
|
4
|
+
return sentences.slice(0, maxSentences).join(' ');
|
|
5
|
+
}
|
|
6
|
+
function buildPurpose(endpoint) {
|
|
7
|
+
if (endpoint.summary)
|
|
8
|
+
return endpoint.summary;
|
|
9
|
+
if (endpoint.description)
|
|
10
|
+
return truncateToSentences(endpoint.description, 2);
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Extracts explicit validation rules from a request body schema:
|
|
15
|
+
* format, minLength/maxLength, minimum/maximum, pattern, enum.
|
|
16
|
+
*
|
|
17
|
+
* Deliberate decision: `required` does NOT produce its own line. The
|
|
18
|
+
* worked example in section 3.3 shows zero "X is required" lines for a
|
|
19
|
+
* register endpoint where email/password are almost certainly required —
|
|
20
|
+
* presence is already conveyed by the field simply appearing in the
|
|
21
|
+
* Request example, so a separate "required" line would be redundant
|
|
22
|
+
* noise for the AI consumer. Reproduced exactly against that example.
|
|
23
|
+
*
|
|
24
|
+
* `ancestors` tracks resolved schema objects on the current recursion
|
|
25
|
+
* path by identity — dereferenced schemas can be real object cycles
|
|
26
|
+
* (recursive DTOs). Unlike a plain depth cap (which used to unroll the
|
|
27
|
+
* cycle ~10 times, producing duplicated "parent.parent...x10 email must
|
|
28
|
+
* be valid" lines), stopping the moment a schema repeats means a
|
|
29
|
+
* recursive property contributes at most one pass of rules — its own
|
|
30
|
+
* rules already cover what the cycle would otherwise repeat.
|
|
31
|
+
*/
|
|
32
|
+
function extractValidationRules(schema, prefix = '', ancestors = new Set()) {
|
|
33
|
+
const { schema: resolved } = resolveUnion(schema);
|
|
34
|
+
if (!resolved)
|
|
35
|
+
return [];
|
|
36
|
+
if (ancestors.has(resolved))
|
|
37
|
+
return [];
|
|
38
|
+
const properties = resolved.properties ?? {};
|
|
39
|
+
const rules = [];
|
|
40
|
+
ancestors.add(resolved);
|
|
41
|
+
for (const [key, rawPropSchema] of Object.entries(properties)) {
|
|
42
|
+
const name = prefix ? `${prefix}.${key}` : key;
|
|
43
|
+
const { schema: prop } = resolveUnion(rawPropSchema);
|
|
44
|
+
if (!prop)
|
|
45
|
+
continue;
|
|
46
|
+
if (prop.format)
|
|
47
|
+
rules.push(`${name} must be valid`);
|
|
48
|
+
if (prop.minLength !== undefined)
|
|
49
|
+
rules.push(`${name} min length ${prop.minLength}`);
|
|
50
|
+
if (prop.maxLength !== undefined)
|
|
51
|
+
rules.push(`${name} max length ${prop.maxLength}`);
|
|
52
|
+
if (prop.minimum !== undefined)
|
|
53
|
+
rules.push(`${name} min ${prop.minimum}`);
|
|
54
|
+
if (prop.maximum !== undefined)
|
|
55
|
+
rules.push(`${name} max ${prop.maximum}`);
|
|
56
|
+
if (prop.pattern)
|
|
57
|
+
rules.push(`${name} must match pattern ${prop.pattern}`);
|
|
58
|
+
if (Array.isArray(prop.enum))
|
|
59
|
+
rules.push(`${name} must be one of: ${prop.enum.join(', ')}`);
|
|
60
|
+
if (prop.type === 'object' && prop.properties) {
|
|
61
|
+
rules.push(...extractValidationRules(prop, name, ancestors));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
ancestors.delete(resolved);
|
|
65
|
+
return rules;
|
|
66
|
+
}
|
|
67
|
+
const PARAM_GROUP_LABELS = {
|
|
68
|
+
path: 'Path Parameters',
|
|
69
|
+
query: 'Query Parameters',
|
|
70
|
+
header: 'Headers',
|
|
71
|
+
};
|
|
72
|
+
/** Same grouping/order/labels as the visual `ParametersSection` — cookie params stay out of scope for both. */
|
|
73
|
+
const PARAM_GROUP_ORDER = ['path', 'query', 'header'];
|
|
74
|
+
function formatParameterLine(param) {
|
|
75
|
+
const requirement = param.required ? 'required' : 'optional';
|
|
76
|
+
const type = typeof param.schema?.type === 'string' ? param.schema.type : 'unknown';
|
|
77
|
+
const description = param.description ? ` — ${param.description}` : '';
|
|
78
|
+
return `- ${param.name} (${requirement}): ${type}${description}`;
|
|
79
|
+
}
|
|
80
|
+
/** Groups parameters into Path/Query/Headers sub-sections, each with an explicit required/optional per line. */
|
|
81
|
+
function buildParametersBlock(parameters) {
|
|
82
|
+
const groups = PARAM_GROUP_ORDER.map((kind) => ({
|
|
83
|
+
kind,
|
|
84
|
+
items: parameters.filter((p) => p.in === kind),
|
|
85
|
+
})).filter((g) => g.items.length > 0);
|
|
86
|
+
if (groups.length === 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
const body = groups
|
|
89
|
+
.map((g) => `${PARAM_GROUP_LABELS[g.kind]}:\n${g.items.map(formatParameterLine).join('\n')}`)
|
|
90
|
+
.join('\n');
|
|
91
|
+
return `Parameters:\n${body}`;
|
|
92
|
+
}
|
|
93
|
+
function buildErrorResponseLines(responses) {
|
|
94
|
+
return responses
|
|
95
|
+
.filter((r) => /^[45]\d\d$/.test(r.status))
|
|
96
|
+
.map((r) => `${r.status} ${r.description || STATUS_TEXT[r.status] || ''}`.trim());
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Transforms a normalized Endpoint into the plain-text, LLM-ready
|
|
100
|
+
* representation behind the "Copy for AI" button. Pure function, no I/O —
|
|
101
|
+
* runs entirely client-side in well under 5ms.
|
|
102
|
+
*
|
|
103
|
+
* Section order: Endpoint → Purpose → Request → Parameters → Validation →
|
|
104
|
+
* Success Response → Error Responses. Empty sections are omitted, and
|
|
105
|
+
* sections are separated by a blank line for scannability.
|
|
106
|
+
*/
|
|
107
|
+
export function operationToAiText(endpoint) {
|
|
108
|
+
const sections = [];
|
|
109
|
+
sections.push(`Endpoint: ${endpoint.method} ${endpoint.path}`);
|
|
110
|
+
const purpose = buildPurpose(endpoint);
|
|
111
|
+
if (purpose)
|
|
112
|
+
sections.push(`Purpose:\n${purpose}`);
|
|
113
|
+
if (endpoint.requestBody?.schema) {
|
|
114
|
+
const result = buildSchemaExample(endpoint.requestBody.schema);
|
|
115
|
+
if (result)
|
|
116
|
+
sections.push(withUnionNotes(`Request:\n${result.json}`, result.unionSizes));
|
|
117
|
+
}
|
|
118
|
+
const parametersBlock = buildParametersBlock(endpoint.parameters);
|
|
119
|
+
if (parametersBlock)
|
|
120
|
+
sections.push(parametersBlock);
|
|
121
|
+
if (endpoint.requestBody?.schema) {
|
|
122
|
+
const rules = extractValidationRules(endpoint.requestBody.schema);
|
|
123
|
+
if (rules.length > 0) {
|
|
124
|
+
sections.push(`Validation:\n${rules.map((r) => `- ${r}`).join('\n')}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const success = pickPrimarySuccessResponse(endpoint.responses);
|
|
128
|
+
if (success) {
|
|
129
|
+
const result = buildSchemaExample(success.schema);
|
|
130
|
+
const body = result ? withUnionNotes(result.json, result.unionSizes) : (success.description || 'No content');
|
|
131
|
+
sections.push(`Success Response (${success.status}):\n${body}`);
|
|
132
|
+
}
|
|
133
|
+
const errorLines = buildErrorResponseLines(endpoint.responses);
|
|
134
|
+
if (errorLines.length > 0) {
|
|
135
|
+
sections.push(`Error Responses:\n${errorLines.join('\n')}`);
|
|
136
|
+
}
|
|
137
|
+
return sections.join('\n\n');
|
|
138
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "docfy-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Pure OpenAPI document-model + Copy-for-AI transformer shared by docfy-ui and docfy-mcp",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"author": "Marvin Rocha",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/MarvinRF/docfy-core.git"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@apidevtools/swagger-parser": "^12.1.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.16.5",
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"vitest": "^4.1.9"
|
|
36
|
+
}
|
|
37
|
+
}
|