Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npm run test)"
],
"deny": [],
"ask": []
}
}
7 changes: 7 additions & 0 deletions .mock-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"typesDir": "..\\testfront\\types",
"port": 8080,
"hotReload": true,
"cache": true,
"verbose": false
}
91 changes: 89 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,93 @@ All endpoints are documented with examples and you can test them directly from y

---

## 🎯 Field Constraints with JSDoc Annotations

Add validation constraints to your interfaces using JSDoc annotations. This ensures generated mock data follows your API rules.

### Supported Constraints

| Annotation | Type | Example | Description |
|---|---|---|---|
| `@minLength` | string | `@minLength 3` | Minimum string length |
| `@maxLength` | string | `@maxLength 10` | Maximum string length |
| `@pattern` | string | `@pattern ^[a-z]+$` | Regex pattern validation |
| `@min` | number | `@min 1` | Minimum numeric value |
| `@max` | number | `@max 100` | Maximum numeric value |
| `@enum` | any | `@enum ACTIVE,INACTIVE,PENDING` | Allowed values (comma-separated) |

### Usage Examples

```typescript
// @endpoint
export interface Badge {
/** @maxLength 10 */
label: string;

/** @min 1 @max 5 */
level: number;

/** @enum ACTIVE,INACTIVE,PENDING */
status: string;
}
```

Response:
```json
{ "label": "New", "level": 3, "status": "ACTIVE" }
```

### More Examples

```typescript
// @endpoint
export interface User {
id: number;

/** @minLength 3 @maxLength 20 */
username: string;

/** @pattern ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ */
email: string;

/** @min 18 @max 120 */
age: number;
}

// @endpoint
export interface Product {
/** @maxLength 50 */
title: string;

/** @minLength 10 @maxLength 500 */
description: string;

/** @min 0.01 @max 999999.99 */
price: number;

/** @enum DRAFT,PUBLISHED,ARCHIVED */
status: string;
}
```

### How It Works

1. Constraints are extracted from JSDoc comments when generating mock data
2. Intermock generates base mock data
3. The constraint resolver applies your rules to ensure valid data
4. **No validation** - constraints are applied, not enforced. Mocks always return valid data.



## Available Commands

- `npm run dev` - Start development server
- `npm run build` - Compile TypeScript
- `npm start` - Start production server
- `npm test` - Run tests
- `npm test` - Run all tests
- `npm test -- constraintExtractor.test.ts` - Test constraint JSDoc extraction
- `npm test -- constraintValidator.test.ts` - Test constraint validation
- `npm test -- constrainedGenerator.test.ts` - Test constrained data generation

---

Expand All @@ -129,7 +210,13 @@ The server maps URL paths to TypeScript interfaces by converting the route to Pa
- `/user` → looks for `User` interface → returns single object
- `/users` → looks for `User` interface → returns array of 3-10 objects

Only interfaces marked with `// @endpoint` are exposed. The server uses Intermock to parse TypeScript AST and Faker to generate realistic test data. Schemas are cached in memory for performance.
Only interfaces marked with `// @endpoint` are exposed. The server uses Intermock to parse TypeScript AST and Faker to generate realistic test data.

**Constraint Processing**: When a request is made, the system:
1. Extracts JSDoc annotations from each field in the interface
2. Generates initial mock data using Intermock/Faker
3. Applies constraints (length, range, enum, pattern) to ensure valid test data
4. Caches schemas in memory for performance

---

Expand Down
179 changes: 179 additions & 0 deletions src/core/constrainedGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { faker } from '@faker-js/faker';
import { FieldConstraint, FieldConstraints } from '../utils/constraintExtractor';
import {
getStringLengthBounds,
getNumberBounds,
getEnumValues,
getPattern,
} from '../utils/constraintValidator';

/**
* Generates a constrained string value
*/
export function generateConstrainedString(constraints: FieldConstraint[]): string {
const enumValues = getEnumValues(constraints);
if (enumValues && enumValues.length > 0) {
return faker.helpers.arrayElement(enumValues);
}

const pattern = getPattern(constraints);
if (pattern) {
// For patterns, try to generate matching string
return generateStringMatchingPattern(pattern);
}

const { min, max } = getStringLengthBounds(constraints);

// Generate a random string of appropriate length
const length = faker.number.int({ min, max });
return faker.string.alphanumeric(length);
}

/**
* Generates a constrained number value
*/
export function generateConstrainedNumber(constraints: FieldConstraint[]): number {
const enumValues = getEnumValues(constraints);
if (enumValues && enumValues.length > 0) {
const numericValues = enumValues.map((v) => parseFloat(v)).filter((v) => !isNaN(v));
if (numericValues.length > 0) {
return faker.helpers.arrayElement(numericValues);
}
}

const { min, max } = getNumberBounds(constraints);
// Use float generation when either bound is a decimal
if (min % 1 !== 0 || max % 1 !== 0) {
return faker.number.float({ min, max });
}
return faker.number.int({ min, max });
}

/**
* Generates a string that matches a regex pattern.
* Uses heuristics for common patterns; falls back to alphanumeric for others.
*/
function generateStringMatchingPattern(pattern: RegExp): string {
const source = pattern.source;

if (source.includes('[a-z]') || source === '[a-z]*') {
return faker.string.alpha({ length: 10 });
}
if (source.includes('[0-9]') || source === '[0-9]*') {
return faker.string.numeric({ length: 10 });
}
if (source.includes('[a-zA-Z0-9]')) {
return faker.string.alphanumeric({ length: 10 });
}
if (source.includes('@') || source === '^[^@]+@[^@]+\\.[^@]+$') {
return faker.internet.email();
}
if (source.includes('http') || source.includes('://')) {
return faker.internet.url();
}

return faker.string.alphanumeric(10);
}

/**
* Applies constraints to generated mock data
* This function takes intermock-generated data and applies custom constraints
*/
export function applyConstraintsToMock(
mockData: Record<string, unknown>,
fieldConstraints: FieldConstraints,
knownTypes: Record<string, string> = {}
): Record<string, unknown> {
const constrained = { ...mockData };

for (const [fieldName, constraints] of Object.entries(fieldConstraints)) {
if (fieldName in constrained) {
const currentValue = constrained[fieldName];
const fieldConstraintsList = constraints as FieldConstraint[];

// Determine the field type from knownTypes or the actual runtime value
const actualType = knownTypes[fieldName] ?? typeof currentValue;
const isNumeric = actualType === 'number';

if (fieldConstraintsList.some((c) => c.type === 'enum')) {
const enumValues = getEnumValues(fieldConstraintsList);
if (enumValues && enumValues.length > 0) {
if (isNumeric) {
const numericValues = enumValues.map((v) => parseFloat(v)).filter((v) => !isNaN(v));
constrained[fieldName] =
numericValues.length > 0
? faker.helpers.arrayElement(numericValues)
: faker.helpers.arrayElement(enumValues);
} else {
constrained[fieldName] = faker.helpers.arrayElement(enumValues);
}
}
} else if (isNumeric) {
const value = currentValue as number;
const minConstraint = fieldConstraintsList.find((c) => c.type === 'min');
const maxConstraint = fieldConstraintsList.find((c) => c.type === 'max');
const min = minConstraint ? (minConstraint.value as number) : value;
const max = maxConstraint ? (maxConstraint.value as number) : value;

if (value < min || value > max) {
constrained[fieldName] = generateConstrainedNumber(fieldConstraintsList);
}
} else {
constrained[fieldName] = generateConstrainedString(fieldConstraintsList);
}
}
}

return constrained;
}

/**
* Generates a value for a field based on its type and constraints
*/
export function generateFieldValue(
_fieldName: string,
fieldType: string,
constraints: FieldConstraint[] = []
): unknown {
// If we have constraints that hint at the type
const hasStringConstraints = constraints.some((c) =>
['minLength', 'maxLength', 'pattern'].includes(c.type)
);
const hasNumberConstraints = constraints.some((c) =>
['min', 'max'].includes(c.type)
);
const hasEnumConstraints = constraints.some((c) => c.type === 'enum');

if (hasEnumConstraints) {
const enumValues = getEnumValues(constraints);
if (enumValues && enumValues.length > 0) {
if (fieldType === 'number') {
const numericValues = enumValues.map((v) => parseFloat(v)).filter((v) => !isNaN(v));
if (numericValues.length > 0) {
return faker.helpers.arrayElement(numericValues);
}
}
return faker.helpers.arrayElement(enumValues);
}
}

// Generate based on constraints or field type
if (hasStringConstraints || fieldType === 'string') {
return generateConstrainedString(constraints);
}

if (hasNumberConstraints || ['number', 'int', 'integer'].includes(fieldType)) {
return generateConstrainedNumber(constraints);
}

if (fieldType === 'boolean') {
return faker.datatype.boolean();
}

if (fieldType === 'date' || fieldType === 'Date') {
return faker.date.recent();
}

// Default fallback
return generateConstrainedString(constraints);
}
20 changes: 19 additions & 1 deletion src/core/parser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as intermock from 'intermock';
import * as fs from 'fs';
import { MockGenerationOptions } from '../types/config';
import { extractConstraints } from '../utils/constraintExtractor';
import { applyConstraintsToMock } from './constrainedGenerator';

/**
* Generates mock data from a TypeScript interface
Expand Down Expand Up @@ -29,12 +31,28 @@ export function generateMockFromInterface(
});

// Intermock returns an object with the interface name as key
const mockData = output[interfaceName as keyof typeof output];
let mockData = output[interfaceName as keyof typeof output];

if (!mockData) {
throw new Error(`Interface "${interfaceName}" not found in file ${filePath}`);
}

// Extract and apply JSDoc constraints from the interface
try {
const constraints = extractConstraints(filePath, interfaceName);
if (Object.keys(constraints).length > 0) {
mockData = applyConstraintsToMock(
mockData as Record<string, unknown>,
constraints
);
}
} catch (constraintError) {
// Log constraint extraction errors but don't fail the entire generation
console.warn(
`Warning: Failed to extract constraints for ${interfaceName}: ${constraintError instanceof Error ? constraintError.message : String(constraintError)}`
);
}

return mockData as Record<string, unknown>;
} catch (error) {
throw new Error(
Expand Down
Loading