Add index-support, type hints and improve documentation #4

Merged
stne3960 merged 3 commits from feature/allow_index into main 2025-06-16 12:41:04 +02:00
3 changed files with 185 additions and 57 deletions

149
README.md
View File

@ -10,7 +10,7 @@ APImposter is an easy-to-use service built with Spring Boot, enabling quick mock
---
## Key Features
## Features
- **Structured Mock Data:** Organize your mock data in YAML files.
- **Automatic URL Mapping:** Automatically generates URL structure based on your folder hierarchy and YAML file names.
@ -18,80 +18,131 @@ APImposter is an easy-to-use service built with Spring Boot, enabling quick mock
- **Dynamic Responses and Global Placeholders:** Supports dynamic values in responses based on URL parameters and you can define global placeholders to reuse values across multiple responses and systems.
- **Conditional Responses:** Return different mock responses based on request data (headers, query, body, or path).
- **Swagger UI:** Automatically generated Swagger UI from YAML files.
- **Quick Setup:** Easy installation and configuration.
---
## Mocks Structure
YAML defined mocks are stored in a directory defined in `application.properties`. You can organize them in nested folders like this:
Mocks are defined in YAML files and stored in a directory specified via `application.properties`. You can organize them in nested folders:
```
/mocks/
├── globals.yaml
├── system1/
│ ├── users.yaml
│ └── program.yaml
│ └── index.yaml
└── another-system/
├── endpoint1.yaml
└── subdir/
└── endpoint2.yaml
├── endpoint2.yaml
└── index.yaml
```
Example YAML files:
### URL Mapping
- `/system1/users.yaml` defines mocks under `/system1/users/...`
- `/system1/index.yaml` defines mocks under`/system1/...`)
- Multiple endpoints can be grouped in one file
- Folders in the YAML file structure become part of the URL path automatically.
### Grouped endpoints in `users.yaml`
```yaml
# globals.yaml
# mocks/system1/users.yaml
- method: GET
path: "/"
response:
status: 200
body:
users:
- id: 1
name: "Alice"
- id: 2
name: "Bob"
- method: GET
path: "/{id}"
response:
status: 200
body:
id: "{id:int}"
name: "User {id}"
- method: POST
path: "/"
response:
status: 200
body:
message: "User created"
```
### Base-level grouping via `index.yaml`
```yaml
# mocks/system1/index.yaml
- method: GET
path: "/"
response:
status: 200
body:
message: "Welcome to system1"
- method: GET
path: "/info"
response:
status: 200
body:
name: "System One"
version: "1.0"
```
### Placeholder Type Hints
You can include optional type hints in placeholders to ensure JSON values are rendered with correct types.
#### Supported types
| Type | Placeholder | Output |
|---------|------------------|-----------------|
| string | `{name}` | `"Alice"` |
| int | `{id:int}` | `42` *(number)* |
| float | `{price:float}` | `99.95` |
| boolean | `{enabled:bool}` | `true` |
If parsing fails (e.g. `abc` for an `int`), APImposter **falls back to the original string**.
---
## Example Requests and Responses
| YAML file | Request | Response Example |
|------------------------------------|----------------------------------|----------------------------------------------|
| `system1/users.yaml` | `GET /system1/users` | `{ "users": [{ "id": 1 }, { "id": 2 }] }` |
| `system1/users.yaml` | `GET /system1/users/42` | `{ "id": 42, "name": "User 42" }` |
| `system1/users.yaml` | `POST /system1/users` | `{ "message": "User created" }` |
| `system1/index.yaml` | `GET /system1` | `{ "message": "Welcome to system1" }` |
| `system1/index.yaml` | `GET /system1/info` | `{ "name": "System One", "version": "1.0" }` |
| `another-system/subdir/index.yaml` | `GET /another-system/subdir/...` | *(Defined response in file)* |
---
## Globals
Global values can be defined in a dedicated file `globals.yaml`:
```yaml
# mocks/globals.yaml
globals:
currentUserId: "user-123"
```
```yaml
# system1/users.yaml
- method: "GET"
path: "/{id}"
response:
status: 200
headers:
Content-Type: "application/json"
body:
id: "{id}"
name: "Example User"
```
```yaml
# system1/program.yaml
- method: "GET"
path: "/"
delay: 500
response:
status: 200
headers:
Content-Type: "application/json"
body:
programId: 12345
globalUserId: "{globals.currentUserId}"
```
---
## Example Requests and Responses
| YAML file | Request | Response |
| --------------------------------------- |--------------------------------------------|-------------------------------------------|
| `mocks/system1/users.yaml` | `GET /<prefix>/system1/users/123` | `{ "id": "123", "name": "Example User" }` |
| `mocks/system1/program.yaml` | `GET /<prefix>/system1/program` | `{ "programId": 12345, "globalUserId": "user-123" }` |
| `mocks/another-system/subdir/endpoint2` | `GET /<prefix>/another-system/subdir/endpoint2/...` | *(defined response JSON)* |
Dynamic placeholders in responses (`{id}`) and global placeholders (`{globals.currentUserId}`) are automatically replaced with actual URL parameters or globally defined values.
Folders in the YAML file structure become part of the URL path automatically.
You can reference global values using `{globals.key}` or `{globals.key:type}` in responses.
---
## Conditional Responses
APImposter supports **conditional responses**, allowing different mock responses to be returned **based on request values** — like query parameters, headers, path variables, or body fields.
APImposter supports **conditional responses**, allowing different mock responses to be returned **based on request values** - query parameters, headers, path variables, or body fields.
This is useful for simulating realistic API behavior, such as authentication, filtering, or alternate success/error outcomes.
@ -184,7 +235,7 @@ This is useful for simulating realistic API behavior, such as authentication, fi
body:
products:
- id: "t1"
name: "Mechanical Keyboard"
name: "Keyboard"
response:
status: 200
headers: { Content-Type: "application/json" }

View File

@ -46,9 +46,24 @@ public class MockFileParser {
*/
public void parseFile(Path file, String relativePath, List<MockEndpoint> endpoints, MockValidator validator) {
String basePath = "/" + relativePath;
if (basePath.endsWith("/index")) {
basePath = basePath.substring(0, basePath.length() - "/index".length());
}
try (InputStream input = Files.newInputStream(file)) {
List<?> rawList = yaml.load(input);
Object root = yaml.load(input);
// Handle empty file
if (root == null) {
LOGGER.warn("⚠️ Skipping empty YAML file: {}", file);
return;
}
// Ensure the root YAML object is a list
if (!(root instanceof List<?> rawList)) {
LOGGER.error("❌ Expected a YAML list in file: {} but got {}", file, root.getClass().getSimpleName());
return;
}
for (Object obj : rawList) {
try {

View File

@ -6,6 +6,8 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
import dsv.su.apimposter.model.Condition;
import dsv.su.apimposter.model.ConditionalResponse;
@ -28,6 +30,7 @@ public class MockService {
private final MockFileLoader loader;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([\\w\\.]+)(?::(int|float|bool))?}");
/**
* Initializes the mock service with a reference to the file loader,
@ -113,7 +116,12 @@ public class MockService {
/**
* Recursively replaces placeholders in the mock response body with actual values
* from path variables and global placeholders.
* Supports simple and typed placeholders:
* - {@code {id}} - from path variables
* - {@code {id:int}} - from path variables, converted to int
* - {@code {globals.env}} - from globals
* - {@code {globals.port:int}} - from globals, converted to int
* If type conversion fails (e.g., {@code {port:int}} but value is "abc"), the raw string is used.
* @param body The response body template.
* @param pathVariables Extracted values from the request URL path.
* @return Response body with all placeholders replaced.
@ -134,20 +142,74 @@ public class MockService {
.toList();
} else if (body instanceof String str) {
for (var entry : pathVariables.entrySet()) {
str = str.replace("{" + entry.getKey() + "}", entry.getValue());
Matcher matcher = PLACEHOLDER_PATTERN.matcher(str);
// If the string is a single placeholder (e.g. "{id:int}" or "{id}"), resolve and return its value
if (matcher.matches()) {
String key = matcher.group(1);
String type = matcher.group(2);
String raw = resolveValue(key, pathVariables, globals);
return convertWithType(raw, type);
}
for (var entry : globals.entrySet()) {
str = str.replace("{globals." + entry.getKey() + "}", entry.getValue().toString());
// Otherwise: perform inline replacement for all placeholders in the string
StringBuilder sb = new StringBuilder();
while (matcher.find()) {
String key = matcher.group(1);
String type = matcher.group(2);
String raw = resolveValue(key, pathVariables, globals);
Object replacement = convertWithType(raw, type);
matcher.appendReplacement(sb, Matcher.quoteReplacement(String.valueOf(replacement)));
}
return str;
matcher.appendTail(sb);
return sb.toString();
}
return body;
}
/**
* Attempts to convert a raw string into a typed value if a type hint is provided.
* Supported types:
* - {@code int} - Integer.parseInt
* - {@code float} - Double.parseDouble
* - {@code bool} - true if "true", "1", "yes", "on" (case-insensitive)
* @param raw the string value to convert
* @param type the optional type hint
* @return the converted value, or the raw string if conversion fails or type is unknown
*/
private Object convertWithType(String raw, String type) {
if (type == null) return raw;
try {
return switch (type) {
case "int" -> Integer.parseInt(raw);
case "float" -> Double.parseDouble(raw);
case "bool" -> Boolean.parseBoolean(raw.toLowerCase());
default -> raw;
};
} catch (Exception e) {
return raw;
}
}
/**
* Resolves a placeholder value from path variables or globals.
* - If the key starts with "globals.", the value is fetched from globals.
* - Otherwise, it's fetched from the pathVariables map.
* @param key the raw key name (e.g., "id", "globals.env")
* @param pathVariables path values extracted from the URI
* @param globals map of global variables
* @return the resolved string value, or empty string if not found
*/
private String resolveValue(String key, Map<String, String> pathVariables, Map<String, Object> globals) {
if (key.startsWith("globals.")) {
String globalKey = key.substring("globals.".length());
Object globalValue = globals.get(globalKey);
return globalValue != null ? String.valueOf(globalValue) : "";
} else {
return pathVariables.getOrDefault(key, "");
}
}
/**
* Serializes a Java object into a JSON string.
* If serialization fails, returns an error wrapper instead.