Skip to content

Commit 2867e6c

Browse files
committed
Increase test coverage
1 parent d02fff2 commit 2867e6c

24 files changed

+1526
-437
lines changed

.github/workflows/tests_and_coverage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
uses: codecov/test-results-action@v1
4343
with:
4444
token: ${{ secrets.CODECOV_TOKEN }}
45-
# flags: report-type="test_results"
45+
# flags: report-type="test_results"
4646
flags: unittests
4747
files: ./coverage/junit.xml
4848
disable_search: true

EXTENDING.md

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
# Guide to Extending the Code Generator
22

3-
This document provides instructions on how to extend the generator to support new frameworks (like React or Vue) and new HTTP clients (like Axios).
3+
This document provides instructions on how to extend the generator to support new frameworks (like React or Vue) and new
4+
HTTP clients (like Axios).
45

56
## Architectural Overview
67

78
The generator is built on a clean, three-layer architecture designed for extensibility:
89

9-
1. **Core Layer (`src/core`)**: Parses the OpenAPI specification. This layer is completely framework-agnostic.
10-
2. **Analysis / IR Layer (`src/analysis`)**: Analyzes the parsed spec and converts it into a framework-agnostic **Intermediate Representation (IR)**. This IR provides a simple, abstract model of services, forms, lists, and validation rules. This is the key to decoupling.
11-
3. **Generation Layer (`src/generators`)**: Consumes the IR and generates framework-specific code. All framework-specific logic is isolated here.
10+
1. **Core Layer (`src/core`)**: Parses the OpenAPI specification. This layer is completely framework-agnostic.
11+
2. **Analysis / IR Layer (`src/analysis`)**: Analyzes the parsed spec and converts it into a framework-agnostic *
12+
*Intermediate Representation (IR)**. This IR provides a simple, abstract model of services, forms, lists, and
13+
validation rules. This is the key to decoupling.
14+
3. **Generation Layer (`src/generators`)**: Consumes the IR and generates framework-specific code. All
15+
framework-specific logic is isolated here.
1216

1317
To add support for a new technology, you will primarily be working in the **Generation Layer**.
1418

1519
---
1620

1721
## How to Add a New Framework (e.g., React)
1822

19-
Adding a new framework like React involves creating a new set of generators that consume the existing IR from the `src/analysis` layer and emit React-specific code (e.g., TypeScript with JSX).
23+
Adding a new framework like React involves creating a new set of generators that consume the existing IR from the
24+
`src/analysis` layer and emit React-specific code (e.g., TypeScript with JSX).
2025

2126
### 1. Create the Framework Directory
2227

@@ -36,7 +41,9 @@ src/generators/
3641

3742
### 2. Implement the Main Client Generator
3843

39-
Create `src/generators/react/react-client.generator.ts`. This file will be the main orchestrator for your framework's code generation. It must implement the `IClientGenerator` interface. You can use `src/generators/angular/angular-client.generator.ts` as a reference.
44+
Create `src/generators/react/react-client.generator.ts`. This file will be the main orchestrator for your framework's
45+
code generation. It must implement the `IClientGenerator` interface. You can use
46+
`src/generators/angular/angular-client.generator.ts` as a reference.
4047

4148
```typescript
4249
// src/generators/react/react-client.generator.ts
@@ -62,7 +69,7 @@ export class ReactClientGenerator extends AbstractClientGenerator {
6269
// const adminGenerator = new ReactAdminGenerator(...);
6370
// adminGenerator.generate(outputDir);
6471
// }
65-
72+
6673
console.log(`🎉 React client generation complete!`);
6774
}
6875
}
@@ -79,7 +86,8 @@ Add `'react'` to the `framework` option's choices.
7986
```typescript
8087
// src/cli.ts
8188
// ... inside the 'from_openapi' command definition
82-
.addOption(new Option('--framework <framework>', 'Target framework').choices(['angular', 'react', 'vue']))
89+
.
90+
addOption(new Option('--framework <framework>', 'Target framework').choices(['angular', 'react', 'vue']))
8391
```
8492

8593
#### In `src/index.ts`:
@@ -105,11 +113,12 @@ function getGeneratorFactory(framework: string): IClientGenerator {
105113

106114
Your React service generator will generate hooks instead of Angular services.
107115

108-
1. Create `src/generators/react/service/service-method.generator.ts`.
109-
2. Use the `ServiceMethodAnalyzer` from `src/analysis` to get the `ServiceMethodModel` (the IR).
110-
3. Use this model to generate a custom hook (e.g., `useGetUserById`) that uses an HTTP client like `fetch` or `axios`.
116+
1. Create `src/generators/react/service/service-method.generator.ts`.
117+
2. Use the `ServiceMethodAnalyzer` from `src/analysis` to get the `ServiceMethodModel` (the IR).
118+
3. Use this model to generate a custom hook (e.g., `useGetUserById`) that uses an HTTP client like `fetch` or `axios`.
111119

112120
**Example Logic:**
121+
113122
```typescript
114123
// Inside your React Service Method Generator
115124
import { ServiceMethodAnalyzer } from '@src/analysis/service-method-analyzer.ts';
@@ -129,15 +138,18 @@ export const use${pascalCase(model.methodName)} = () => {
129138

130139
If you want to generate an admin UI:
131140

132-
1. Use `FormModelBuilder` and `ListModelBuilder` from `src/analysis` to get the `FormAnalysisResult` and `ListViewModel`.
133-
2. Create React-specific generators that consume this IR to produce JSX.
134-
3. You will need to create a **React-specific renderer for validation**. For example, create a `ValidationRenderer` that converts the `ValidationRule[]` IR into a `Yup` schema for use with Formik.
141+
1. Use `FormModelBuilder` and `ListModelBuilder` from `src/analysis` to get the `FormAnalysisResult` and
142+
`ListViewModel`.
143+
2. Create React-specific generators that consume this IR to produce JSX.
144+
3. You will need to create a **React-specific renderer for validation**. For example, create a `ValidationRenderer` that
145+
converts the `ValidationRule[]` IR into a `Yup` schema for use with Formik.
135146

136147
---
137148

138149
## How to Add a New HTTP Client (e.g., Axios)
139150

140-
This change is much simpler as it's localized within a specific framework's generator. Here’s how to do it for the existing Angular generator.
151+
This change is much simpler as it's localized within a specific framework's generator. Here’s how to do it for the
152+
existing Angular generator.
141153

142154
### 1. Locate the HTTP Call Logic
143155

@@ -175,8 +187,9 @@ Replace the `this.http.*` calls with your desired client's syntax.
175187

176188
**To switch to Axios, you would:**
177189

178-
1. Change the imports at the top of `src/generators/angular/service/service.generator.ts` to import `axios` and `from` from `rxjs` (to wrap the Promise in an Observable).
179-
2. Modify the `emitMethodBody` logic to build an `axios` config object and make the call.
190+
1. Change the imports at the top of `src/generators/angular/service/service.generator.ts` to import `axios` and `from`
191+
from `rxjs` (to wrap the Promise in an Observable).
192+
2. Modify the `emitMethodBody` logic to build an `axios` config object and make the call.
180193

181194
**Example Change (Conceptual):**
182195

@@ -193,4 +206,5 @@ lines.push(`const config = { headers, params };`);
193206
lines.push(`return from(axios.get(url, config));`);
194207
```
195208

196-
You would need to adapt the `requestOptions` object to the format expected by Axios. You could also add a configuration option in `config.ts` to let the user choose which HTTP client to generate code for.
209+
You would need to adapt the `requestOptions` object to the format expected by Axios. You could also add a configuration
210+
option in `config.ts` to let the user choose which HTTP client to generate code for.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ npm install
168168
npm run build
169169
npm install -g .
170170
```
171+
171172
(I'll put it up on npmjs soon)
172173

173174
## Usage

src/generators/angular/utils/index.generator.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,24 @@ export class ServiceIndexGenerator {
6969

7070
public generateIndex(outputRoot: string): void {
7171
const servicesDir = path.join(outputRoot, "services");
72+
73+
// Use path.resolve for robust comparison of directory paths (normalizes separators and makes absolute)
74+
// This handles cases where ts-morph internal paths and input path formats differ (e.g. windows vs posix)
75+
const absServicesDir = path.resolve(servicesDir);
76+
77+
const serviceFiles = this.project.getSourceFiles().filter(sf => {
78+
const absFileDir = path.resolve(path.dirname(sf.getFilePath()));
79+
return absFileDir === absServicesDir && sf.getFilePath().endsWith('.service.ts');
80+
});
81+
82+
if (serviceFiles.length === 0) return;
83+
84+
// Create index file
7285
const indexPath = path.join(servicesDir, "index.ts");
7386
const sourceFile = this.project.createSourceFile(indexPath, "", { overwrite: true });
7487

7588
sourceFile.insertText(0, SERVICE_INDEX_GENERATOR_HEADER_COMMENT);
7689

77-
const servicesDirectory = this.project.getDirectory(servicesDir);
78-
if (!servicesDirectory) return;
79-
80-
const serviceFiles = servicesDirectory.getSourceFiles()
81-
.filter(sf => sf.getFilePath().endsWith('.service.ts'));
82-
8390
for (const serviceFile of serviceFiles) {
8491
const serviceClass = serviceFile.getClasses()[0];
8592
const className = serviceClass?.getName();
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { ReferenceResolver } from '@src/core/parser/reference-resolver.js';
3+
import { SwaggerSpec } from "@src/core/types/index.js";
4+
5+
describe('Core: ReferenceResolver', () => {
6+
let cache: Map<string, SwaggerSpec>;
7+
let resolver: ReferenceResolver;
8+
const rootUri = 'file:///root.json';
9+
10+
beforeEach(() => {
11+
cache = new Map();
12+
cache.set(rootUri, { openapi: '3.0.0', paths: {} } as any);
13+
resolver = new ReferenceResolver(cache, rootUri);
14+
});
15+
16+
describe('indexSchemaIds', () => {
17+
it('should index standard $id and anchors', () => {
18+
const spec = {
19+
schemas: {
20+
User: { $id: 'http://example.com/user', $anchor: 'local', $dynamicAnchor: 'dyn' }
21+
}
22+
};
23+
ReferenceResolver.indexSchemaIds(spec, rootUri, cache);
24+
expect(cache.has('http://example.com/user')).toBe(true);
25+
expect(cache.has('http://example.com/user#local')).toBe(true);
26+
expect(cache.has('http://example.com/user#dyn')).toBe(true);
27+
});
28+
29+
it('should safely ignore invalid IDs', () => {
30+
const spec = { schemas: { Bad: { $id: 'invalid-uri' } } };
31+
expect(() => ReferenceResolver.indexSchemaIds(spec, rootUri, cache)).not.toThrow();
32+
});
33+
});
34+
35+
describe('resolveReference', () => {
36+
it('should handle JSON pointer traversal', () => {
37+
cache.set(rootUri, { nested: { val: 123 } } as any);
38+
const res = resolver.resolveReference('#/nested/val');
39+
expect(res).toBe(123);
40+
});
41+
42+
it('should return undefined and warn when traversal fails on missing property', () => {
43+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {
44+
});
45+
cache.set(rootUri, { nested: {} } as any);
46+
const res = resolver.resolveReference('#/nested/missing');
47+
expect(res).toBeUndefined();
48+
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Failed to resolve reference part "missing"'));
49+
});
50+
51+
// ** NEW COVERAGE **
52+
// Hits line 144 logic: `result` is valid object but doesn't have property
53+
it('should warn if property access fails during traversal', () => {
54+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {
55+
});
56+
cache.set(rootUri, { a: { b: 1 } } as any);
57+
// 'c' doesn't exist on { b: 1 }
58+
const res = resolver.resolveReference('#/a/c');
59+
expect(res).toBeUndefined();
60+
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Failed to resolve reference part "c"'));
61+
});
62+
63+
it('should return undefined and warn when traversal fails on null intermediate', () => {
64+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {
65+
});
66+
cache.set(rootUri, { nested: null } as any);
67+
const res = resolver.resolveReference('#/nested/child');
68+
expect(res).toBeUndefined();
69+
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Failed to resolve reference part "child"'));
70+
});
71+
72+
it('should resolve references to external files in cache', () => {
73+
cache.set('http://external.com/doc.json', { id: 'extern' } as any);
74+
const res = resolver.resolveReference('http://external.com/doc.json#/id');
75+
expect(res).toBe('extern');
76+
});
77+
78+
it('should return undefined if external file missing from cache', () => {
79+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {
80+
});
81+
const res = resolver.resolveReference('http://missing.com/doc.json');
82+
expect(res).toBeUndefined();
83+
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Unresolved external file reference'));
84+
});
85+
86+
it('should warn on invalid reference type input', () => {
87+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {
88+
});
89+
const res = resolver.resolveReference(123 as any);
90+
expect(res).toBeUndefined();
91+
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Encountered an unsupported or invalid reference'));
92+
});
93+
});
94+
95+
describe('findRefs', () => {
96+
it('should find all $ref and $dynamicRef strings', () => {
97+
const obj = {
98+
a: { $ref: '#/a' },
99+
b: [{ $dynamicRef: '#/b' }],
100+
c: 'not-a-ref'
101+
};
102+
const refs = ReferenceResolver.findRefs(obj);
103+
expect(refs).toContain('#/a');
104+
expect(refs).toContain('#/b');
105+
expect(refs.length).toBe(2);
106+
});
107+
});
108+
109+
describe('resolve', () => {
110+
it('should augment resolved object with summary/description from ref wrapper', () => {
111+
cache.set(rootUri, { defs: { Target: { type: 'string', description: 'Original' } } } as any);
112+
const refObj = {
113+
$ref: '#/defs/Target',
114+
description: 'Overridden',
115+
summary: 'Summary'
116+
};
117+
const res: any = resolver.resolve(refObj);
118+
expect(res.type).toBe('string');
119+
expect(res.description).toBe('Overridden');
120+
expect(res.summary).toBe('Summary');
121+
});
122+
123+
it('should return null/undefined if input is null/undefined', () => {
124+
expect(resolver.resolve(null)).toBeUndefined();
125+
expect(resolver.resolve(undefined)).toBeUndefined();
126+
});
127+
128+
it('should return input object if it is not a reference', () => {
129+
const obj = { type: 'number' };
130+
expect(resolver.resolve(obj)).toBe(obj);
131+
});
132+
});
133+
});

0 commit comments

Comments
 (0)