Skip to content

Commit 70c82c9

Browse files
authored
Merge pull request #64 from sacconazzo/develop
v2.3
2 parents 50195ac + 7435fb9 commit 70c82c9

File tree

8 files changed

+357
-13
lines changed

8 files changed

+357
-13
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,38 @@ components:
7171

7272
For each endpoint extension, you can define OpenAPI partials by adding an `oas.yaml` file in the root of that endpoint's folder.
7373

74+
### Non-bundled extensions
75+
76+
Place the `oas.yaml` file directly in the extension folder:
77+
78+
```
79+
- ./extensions/
80+
─ oasconfig.yaml (optional)
81+
- my-endpoint-extension/
82+
- oas.yaml
83+
```
84+
85+
### Bundled extensions
86+
87+
For bundled extensions, place `oas.yaml` files in each sub-extension's folder under the `src` directory:
88+
89+
```
90+
- ./extensions/
91+
─ oasconfig.yaml (optional)
92+
- my-bundle-extension/
93+
- src/
94+
- routes-endpoint/
95+
- oas.yaml
96+
- admin-endpoint/
97+
- oas.yaml
98+
```
99+
100+
This structure follows Directus's standard bundle architecture where each sub-extension (routes, endpoints, hooks, etc.) has its own folder under `src/`. The extension will automatically discover and merge all `oas.yaml` files from these subdirectories.
101+
102+
### Mixed environments
103+
104+
Both bundled and non-bundled extensions can coexist in the same project. The extension will automatically detect and merge all `oas.yaml` files from both types.
105+
74106
Properties:
75107

76108
- `tags` _optional_ openapi custom tags

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "directus-extension-api-docs",
3-
"version": "2.2.3",
3+
"version": "2.3.0",
44
"description": "directus extension for swagger interface and openapi including custom endpoints definitions // custom endpoint validations middleware based on openapi",
55
"licence": "MIT",
66
"icon": "api",

src/utils.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,19 +93,43 @@ export function getConfig(): oasConfig {
9393
config.components = merge(config.components || {}, oas.components || {});
9494
};
9595

96+
const scanDirectory = (dirPath: string) => {
97+
if (!fs.existsSync(dirPath)) return;
98+
99+
const files = fs.readdirSync(dirPath, { withFileTypes: true });
100+
for (const file of files) {
101+
if (!file.isDirectory()) continue;
102+
103+
const extensionPath = path.join(dirPath, file.name);
104+
105+
// Check for oas.yaml at root level (non-bundle extensions)
106+
const rootOasPath = path.join(extensionPath, 'oas.yaml');
107+
if (fs.existsSync(rootOasPath)) {
108+
mergeConfig(rootOasPath);
109+
}
110+
111+
// Check for oas.yaml in src subdirectories (bundled extensions - green option)
112+
const srcPath = path.join(extensionPath, 'src');
113+
if (fs.existsSync(srcPath)) {
114+
const srcFiles = fs.readdirSync(srcPath, { withFileTypes: true });
115+
for (const srcFile of srcFiles) {
116+
if (srcFile.isDirectory()) {
117+
const bundleOasPath = path.join(srcPath, srcFile.name, 'oas.yaml');
118+
if (fs.existsSync(bundleOasPath)) {
119+
mergeConfig(bundleOasPath);
120+
}
121+
}
122+
}
123+
}
124+
}
125+
};
126+
96127
const extensionsPath = path.join(directusDir(), extensionDir);
97-
const files = fs.readdirSync(extensionsPath, { withFileTypes: true });
98-
for (const file of files) {
99-
const oasPath = `${extensionsPath}/${file.name}/oas.yaml`;
100-
if (file.isDirectory() && fs.existsSync(oasPath)) mergeConfig(oasPath);
101-
}
128+
scanDirectory(extensionsPath);
102129

130+
// Legacy support for /endpoints subfolder
103131
const legacyEndpointsPath = path.join(directusDir(), extensionDir, '/endpoints');
104-
const legacyFiles = fs.readdirSync(legacyEndpointsPath, { withFileTypes: true });
105-
for (const file of legacyFiles) {
106-
const oasPath = `${legacyEndpointsPath}/${file.name}/oas.yaml`;
107-
if (file.isDirectory() && fs.existsSync(oasPath)) mergeConfig(oasPath);
108-
}
132+
scanDirectory(legacyEndpointsPath);
109133

110134
return config;
111135
} catch (e) {

tests/index.test.ts

Lines changed: 210 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { oasConfig, oas } from '../src/types';
2-
import { getConfig, getPackage, filterPaths } from '../src/utils';
2+
import { getConfig, getPackage, filterPaths, merge } from '../src/utils';
33

44
describe('openapi config generation', () => {
55
afterAll(async () => {
@@ -71,10 +71,19 @@ describe('getPackage', () => {
7171
expect(test).toHaveProperty('version');
7272
expect(test).toHaveProperty('description');
7373
});
74+
test('should return empty object when package.json not found', async () => {
75+
const originalCwd = process.cwd();
76+
jest.spyOn(process, 'cwd').mockImplementation(() => {
77+
return '/nonexistent/path';
78+
});
79+
const test = await getPackage();
80+
expect(test).toEqual({});
81+
process.cwd = () => originalCwd;
82+
});
7483
});
7584

7685
describe('filterPaths', () => {
77-
test('should be valid', () => {
86+
test('should filter paths based on published tags', () => {
7887
const oasConfig: oasConfig = {
7988
docsPath: 'api-docs',
8089
info: {},
@@ -113,4 +122,203 @@ describe('filterPaths', () => {
113122
expect(oas.tags.length).toEqual(1);
114123
expect(oas.tags[0].name).toEqual('tag2');
115124
});
125+
126+
test('should handle empty publishedTags array', () => {
127+
const oasConfig: oasConfig = {
128+
docsPath: 'api-docs',
129+
info: {},
130+
useAuthentication: true,
131+
tags: [],
132+
components: {},
133+
publishedTags: [],
134+
paths: {},
135+
};
136+
const oas: oas = {
137+
info: {},
138+
tags: [{ name: 'tag1' }, { name: 'tag2' }],
139+
components: {},
140+
paths: {
141+
endpoint1: {
142+
get: { tags: ['tag1'] },
143+
},
144+
},
145+
};
146+
filterPaths(oasConfig, oas);
147+
expect(oas.paths.endpoint1?.get).toBeUndefined();
148+
expect(oas.tags.length).toEqual(0);
149+
});
150+
151+
test('should handle endpoints with multiple published tags', () => {
152+
const oasConfig: oasConfig = {
153+
docsPath: 'api-docs',
154+
info: {},
155+
useAuthentication: true,
156+
tags: [],
157+
components: {},
158+
publishedTags: ['tag1', 'tag2', 'tag3'],
159+
paths: {},
160+
};
161+
const oas: oas = {
162+
info: {},
163+
tags: [{ name: 'tag1' }, { name: 'tag2' }, { name: 'tag3' }],
164+
components: {},
165+
paths: {
166+
endpoint1: {
167+
get: { tags: ['tag1', 'tag2', 'tag3'] },
168+
},
169+
},
170+
};
171+
filterPaths(oasConfig, oas);
172+
expect(oas.paths.endpoint1).toHaveProperty('get');
173+
expect(oas.paths.endpoint1?.get?.tags.length).toEqual(3);
174+
expect(oas.tags.length).toEqual(3);
175+
});
176+
177+
test('should handle endpoints without tags', () => {
178+
const oasConfig: oasConfig = {
179+
docsPath: 'api-docs',
180+
info: {},
181+
useAuthentication: true,
182+
tags: [],
183+
components: {},
184+
publishedTags: ['tag1'],
185+
paths: {},
186+
};
187+
const oas: oas = {
188+
info: {},
189+
tags: [{ name: 'tag1' }],
190+
components: {},
191+
paths: {
192+
endpoint1: {
193+
get: { tags: [] },
194+
},
195+
},
196+
};
197+
filterPaths(oasConfig, oas);
198+
expect(oas.paths.endpoint1?.get).toBeUndefined();
199+
});
200+
});
201+
202+
describe('merge', () => {
203+
test('should deep merge two simple objects', () => {
204+
const obj1 = { a: 1, b: 2 };
205+
const obj2 = { b: 3, c: 4 };
206+
const result = merge(obj1, obj2);
207+
expect(result).toEqual({ a: 1, b: 3, c: 4 });
208+
});
209+
210+
test('should deep merge nested objects', () => {
211+
const obj1 = { a: { x: 1, y: 2 }, b: 3 };
212+
const obj2 = { a: { y: 3, z: 4 }, c: 5 };
213+
const result = merge(obj1, obj2);
214+
expect(result).toEqual({ a: { x: 1, y: 3, z: 4 }, b: 3, c: 5 });
215+
});
216+
217+
test('should merge arrays', () => {
218+
const obj1 = { arr: [1, 2] };
219+
const obj2 = { arr: [3, 4] };
220+
const result = merge(obj1, obj2);
221+
expect(result.arr).toEqual([3, 4]);
222+
});
223+
224+
test('should handle empty objects', () => {
225+
const obj1 = {};
226+
const obj2 = { a: 1, b: 2 };
227+
const result = merge(obj1, obj2);
228+
expect(result).toEqual({ a: 1, b: 2 });
229+
});
230+
231+
test('should merge complex nested structures', () => {
232+
const obj1 = {
233+
components: {
234+
schemas: { User: { type: 'object' } },
235+
responses: { 200: { description: 'OK' } },
236+
},
237+
};
238+
const obj2 = {
239+
components: {
240+
schemas: { Post: { type: 'object' } },
241+
parameters: { id: { in: 'path' } },
242+
},
243+
};
244+
const result = merge(obj1, obj2);
245+
expect(result.components.schemas).toHaveProperty('User');
246+
expect(result.components.schemas).toHaveProperty('Post');
247+
expect(result.components.responses).toHaveProperty('200');
248+
expect(result.components.parameters).toHaveProperty('id');
249+
});
250+
251+
test('should handle null and undefined values', () => {
252+
const obj1 = { a: 1, b: null };
253+
const obj2 = { b: 2, c: undefined };
254+
const result = merge(obj1, obj2);
255+
expect(result).toEqual({ a: 1, b: 2, c: undefined });
256+
});
257+
258+
test('should overwrite primitive values', () => {
259+
const obj1 = { a: 'hello', b: 10, c: true };
260+
const obj2 = { a: 'world', b: 20 };
261+
const result = merge(obj1, obj2);
262+
expect(result).toEqual({ a: 'world', b: 20, c: true });
263+
});
264+
});
265+
266+
describe('getConfig edge cases', () => {
267+
test('should return default config when no oasconfig exists', () => {
268+
jest.spyOn(process, 'cwd').mockImplementation(() => {
269+
return './tests/mocks/nonexistent';
270+
});
271+
const test = getConfig();
272+
expect(test.docsPath).toBe('api-docs');
273+
expect(test.useAuthentication).toBe(false);
274+
expect(test.tags).toEqual([]);
275+
expect(test.publishedTags).toEqual([]);
276+
});
277+
278+
test('should handle missing oas.yaml files gracefully', () => {
279+
jest.spyOn(process, 'cwd').mockImplementation(() => {
280+
return './tests/mocks/oasconfig';
281+
});
282+
const test = getConfig();
283+
expect(test).toBeDefined();
284+
expect(test.docsPath).toBeDefined();
285+
});
286+
});
287+
288+
describe('bundled extension support', () => {
289+
test('should merge oas.yaml from bundle extension src subdirectories', () => {
290+
jest.spyOn(process, 'cwd').mockImplementation(() => {
291+
return './tests/mocks/bundle';
292+
});
293+
const test = getConfig();
294+
expect(test).toHaveProperty('docsPath');
295+
expect(test).toHaveProperty('tags');
296+
expect(test).toHaveProperty('paths');
297+
expect(test).toHaveProperty('components');
298+
299+
// Check that routes from bundled sub-extensions are included
300+
expect(test.paths).toHaveProperty('/bundle/items');
301+
expect(test.paths).toHaveProperty('/bundle/users');
302+
303+
// Check that tags from bundled sub-extensions are included
304+
expect(test.tags).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'bundle-routes' }), expect.objectContaining({ name: 'bundle-users' })]));
305+
306+
// Check that components from bundled sub-extensions are included
307+
expect(test.components).toHaveProperty('schemas');
308+
expect(test.components.schemas).toHaveProperty('BundleItem');
309+
});
310+
311+
test('should merge both bundled and non-bundled extensions', () => {
312+
jest.spyOn(process, 'cwd').mockImplementation(() => {
313+
return './tests/mocks/mixed';
314+
});
315+
const test = getConfig();
316+
317+
// Check that routes from both types are included
318+
expect(test.paths).toHaveProperty('/regular/endpoint');
319+
expect(test.paths).toHaveProperty('/bundle/route-a');
320+
321+
// Check that tags from both types are included
322+
expect(test.tags).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'regular' }), expect.objectContaining({ name: 'bundle-a' })]));
323+
});
116324
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Bundle Extension API
4+
version: 1.0.0
5+
description: API from bundled extension
6+
tags:
7+
- name: bundle-routes
8+
description: Routes from bundle extension
9+
paths:
10+
/bundle/items:
11+
get:
12+
tags:
13+
- bundle-routes
14+
summary: Get items from bundle
15+
responses:
16+
'200':
17+
description: Success
18+
content:
19+
application/json:
20+
schema:
21+
type: array
22+
items:
23+
type: object
24+
components:
25+
schemas:
26+
BundleItem:
27+
type: object
28+
properties:
29+
id:
30+
type: string
31+
name:
32+
type: string
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Another Bundle Route
4+
version: 1.0.0
5+
tags:
6+
- name: bundle-users
7+
description: User routes from bundle
8+
paths:
9+
/bundle/users:
10+
get:
11+
tags:
12+
- bundle-users
13+
summary: Get users from bundle
14+
responses:
15+
'200':
16+
description: Success
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Bundle Extension Routes A
4+
version: 1.0.0
5+
tags:
6+
- name: bundle-a
7+
description: Bundled extension route A
8+
paths:
9+
/bundle/route-a:
10+
get:
11+
tags:
12+
- bundle-a
13+
summary: Bundle route A
14+
responses:
15+
'200':
16+
description: Success

0 commit comments

Comments
 (0)