Add relative time and date #6
60
README.md
60
README.md
@ -111,6 +111,66 @@ You can include optional type hints in placeholders to ensure JSON values are re
|
||||
|
||||
If parsing fails (e.g. `abc` for an `int`), APImposter **falls back to the original string**.
|
||||
|
||||
### Relative Date/Time Placeholders
|
||||
|
||||
APImposter supports dynamic date and time placeholders that are calculated relative to the current time. This is perfect for testing calendar applications, scheduling systems, or any time-sensitive mock data.
|
||||
|
||||
#### Supported formats
|
||||
|
||||
| Format | Example | Description |
|
||||
|--------|---------|-------------|
|
||||
| `{datetime+/-offset}` | `{datetime+2d4h30m}` | DateTime with flexible time offset |
|
||||
| `{datetime+/-Nd@HH:MM}` | `{datetime+1d@13:30}` | DateTime N days from now at specific time |
|
||||
| `{date+/-Nd}` | `{date-7d}` | Date N days from now |
|
||||
|
||||
#### Format specifiers (optional)
|
||||
|
||||
Add a format specifier after a colon to control the output format:
|
||||
|
||||
| Specifier | Description | Example Output |
|
||||
|-----------|-------------|----------------|
|
||||
| `iso` (default) | ISO standard format | `2025-09-03T15:30:00` |
|
||||
| `rfc3339` | RFC 3339 with UTC timezone | `2025-09-03T15:30:00Z` |
|
||||
| Custom pattern | Any valid DateTimeFormatter pattern | `Sep 03, 2025` |
|
||||
|
||||
#### Examples
|
||||
|
||||
```yaml
|
||||
# Basic relative dates (ISO format)
|
||||
- method: GET
|
||||
path: "/events"
|
||||
response:
|
||||
body:
|
||||
todayMeeting: "{datetime+0d@08:00}" # Today at 8:00 AM
|
||||
tomorrowLunch: "{datetime+1d@13:00}" # Tomorrow at 1:00 PM
|
||||
nextWeek: "{date+7d}" # Date 7 days from now
|
||||
pastEvent: "{datetime-2d4h30m}" # 2 days, 4h, 30m ago
|
||||
yesterday: "{date-1d}" # Yesterday's date
|
||||
|
||||
# Different output formats
|
||||
- method: GET
|
||||
path: "/calendar"
|
||||
response:
|
||||
body:
|
||||
# RFC3339 format
|
||||
eventTimeRfc: "{datetime+1d@15:30:rfc3339}" # "2025-09-03T15:30:00Z"
|
||||
|
||||
# Custom formats
|
||||
eventDate: "{date+1d:MMM dd, yyyy}" # "Sep 03, 2025"
|
||||
timeOnly: "{datetime+0d@14:30:HH:mm}" # "14:30"
|
||||
shortDate: "{date+7d:MM/dd/yy}" # "09/09/25"
|
||||
```
|
||||
|
||||
#### Time components for offset format
|
||||
|
||||
- `d` = days
|
||||
- `h` = hours
|
||||
- `m` = minutes
|
||||
|
||||
All components are optional and can be combined: `{datetime+1d2h}`, `{datetime+30m}`, `{datetime-4h15m}`
|
||||
|
||||
**Note:** If a custom format is invalid, the system automatically falls back to ISO format to prevent errors.
|
||||
|
||||
---
|
||||
|
||||
## Example Requests and Responses
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
package dsv.su.apimposter.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@ -30,7 +35,20 @@ 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))?}");
|
||||
// Patterns using named capture groups (Java 21+)
|
||||
private static final Pattern PLACEHOLDER_PATTERN =
|
||||
Pattern.compile("\\{(?<key>[\\w\\.]+)(?::(?<type>int|float|bool))?}");
|
||||
|
||||
// Patterns for relative date/time placeholders with optional format specifier
|
||||
private static final Pattern DATETIME_OFFSET_PATTERN =
|
||||
Pattern.compile("\\{datetime(?<sign>[+-])(?<days>\\d+d)?(?<hours>\\d+h)?"
|
||||
+ "(?<minutes>\\d+m)?(?::(?<format>\\w+|[^}]+))?}");
|
||||
private static final Pattern DATETIME_AT_TIME_PATTERN =
|
||||
Pattern.compile("\\{datetime(?<sign>[+-])(?<days>\\d+)d@(?<time>\\d{1,2}:\\d{2})"
|
||||
+ "(?::(?<format>\\w+|[^}]+))?}");
|
||||
private static final Pattern DATE_OFFSET_PATTERN =
|
||||
Pattern.compile("\\{date(?<sign>[+-])(?<days>\\d+)d(?::(?<format>\\w+|[^}]+))?}");
|
||||
|
||||
|
||||
/**
|
||||
* Initializes the mock service with a reference to the file loader,
|
||||
@ -142,11 +160,17 @@ public class MockService {
|
||||
.toList();
|
||||
|
||||
} else if (body instanceof String str) {
|
||||
// Check for relative date/time placeholders first
|
||||
String dateTimeResult = processDateTimePlaceholders(str);
|
||||
if (!dateTimeResult.equals(str)) {
|
||||
return dateTimeResult; // Date/time placeholder was processed
|
||||
}
|
||||
|
||||
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 key = matcher.group("key");
|
||||
String type = matcher.group("type");
|
||||
String raw = resolveValue(key, pathVariables, globals);
|
||||
return convertWithType(raw, type);
|
||||
}
|
||||
@ -154,8 +178,8 @@ public class MockService {
|
||||
// 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 key = matcher.group("key");
|
||||
String type = matcher.group("type");
|
||||
String raw = resolveValue(key, pathVariables, globals);
|
||||
Object replacement = convertWithType(raw, type);
|
||||
matcher.appendReplacement(sb, Matcher.quoteReplacement(String.valueOf(replacement)));
|
||||
@ -167,6 +191,171 @@ public class MockService {
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes relative date/time placeholders in a string.
|
||||
* Supports three formats with optional format specifiers:
|
||||
* - {datetime+2d4h30m[:format]} - datetime now +/- time offset
|
||||
* - {datetime+2d@13:00[:format]} - datetime now +/- days at specific time
|
||||
* - {date-4d[:format]} - date now +/- days
|
||||
*
|
||||
* Format specifiers:
|
||||
* - iso (default) - ISO standard format
|
||||
* - rfc3339 - RFC 3339 format with UTC timezone
|
||||
* - custom pattern - Valid DateTimeFormatter pattern
|
||||
*
|
||||
* Examples:
|
||||
* - {datetime+1d} - "2025-09-03T14:30:00"
|
||||
* - {datetime+1d:rfc3339} - "2025-09-03T14:30:00Z"
|
||||
* - {date+1d:MMM dd, yyyy} - "Sep 03, 2025"
|
||||
*
|
||||
* @param input the input string that may contain date/time placeholders
|
||||
* @return the string with date/time placeholders replaced, or original string if no placeholders found
|
||||
*/
|
||||
private String processDateTimePlaceholders(String input) {
|
||||
String result = input;
|
||||
|
||||
// Process datetime with offset (e.g., {datetime+2d4h30m} or {datetime+2d4h30m:rfc3339})
|
||||
Matcher dateTimeOffsetMatcher = DATETIME_OFFSET_PATTERN.matcher(result);
|
||||
if (dateTimeOffsetMatcher.find()) {
|
||||
String sign = dateTimeOffsetMatcher.group("sign");
|
||||
String daysStr = dateTimeOffsetMatcher.group("days");
|
||||
String hoursStr = dateTimeOffsetMatcher.group("hours");
|
||||
String minutesStr = dateTimeOffsetMatcher.group("minutes");
|
||||
String format = dateTimeOffsetMatcher.group("format");
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime target = calculateDateTimeWithOffset(now, sign, daysStr, hoursStr, minutesStr);
|
||||
|
||||
String replacement = formatDateTime(target, format);
|
||||
result = dateTimeOffsetMatcher.replaceFirst(replacement);
|
||||
}
|
||||
|
||||
// Process datetime with days at specific time (e.g., {datetime+2d@13:00} or {datetime+2d@13:00:rfc3339})
|
||||
Matcher dateTimeAtTimeMatcher = DATETIME_AT_TIME_PATTERN.matcher(result);
|
||||
if (dateTimeAtTimeMatcher.find()) {
|
||||
String sign = dateTimeAtTimeMatcher.group("sign");
|
||||
int days = Integer.parseInt(dateTimeAtTimeMatcher.group("days"));
|
||||
String timeStr = dateTimeAtTimeMatcher.group("time");
|
||||
String format = dateTimeAtTimeMatcher.group("format");
|
||||
|
||||
LocalDate targetDate = LocalDate.now();
|
||||
if ("+".equals(sign)) {
|
||||
targetDate = targetDate.plusDays(days);
|
||||
} else {
|
||||
targetDate = targetDate.minusDays(days);
|
||||
}
|
||||
|
||||
LocalTime time = LocalTime.parse(timeStr);
|
||||
LocalDateTime target = LocalDateTime.of(targetDate, time);
|
||||
|
||||
String replacement = formatDateTime(target, format);
|
||||
result = dateTimeAtTimeMatcher.replaceFirst(replacement);
|
||||
}
|
||||
|
||||
// Process date with offset (e.g., {date-4d} or {date-4d:MMM dd, yyyy})
|
||||
Matcher dateOffsetMatcher = DATE_OFFSET_PATTERN.matcher(result);
|
||||
if (dateOffsetMatcher.find()) {
|
||||
String sign = dateOffsetMatcher.group("sign");
|
||||
int days = Integer.parseInt(dateOffsetMatcher.group("days"));
|
||||
String format = dateOffsetMatcher.group("format");
|
||||
|
||||
LocalDate target = LocalDate.now();
|
||||
if ("+".equals(sign)) {
|
||||
target = target.plusDays(days);
|
||||
} else {
|
||||
target = target.minusDays(days);
|
||||
}
|
||||
|
||||
String replacement = formatDate(target, format);
|
||||
result = dateOffsetMatcher.replaceFirst(replacement);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a LocalDateTime with the given offset components.
|
||||
* @param base the base datetime to start from
|
||||
* @param sign "+" or "-" to indicate direction
|
||||
* @param daysStr days component (e.g., "2d")
|
||||
* @param hoursStr hours component (e.g., "4h")
|
||||
* @param minutesStr minutes component (e.g., "30m")
|
||||
* @return the calculated LocalDateTime
|
||||
*/
|
||||
private LocalDateTime calculateDateTimeWithOffset(LocalDateTime base, String sign, String daysStr,
|
||||
String hoursStr, String minutesStr)
|
||||
{
|
||||
LocalDateTime result = base;
|
||||
|
||||
int multiplier = "+".equals(sign) ? 1 : -1;
|
||||
|
||||
if (daysStr != null) {
|
||||
int days = Integer.parseInt(daysStr.replace("d", ""));
|
||||
result = result.plusDays(days * multiplier);
|
||||
}
|
||||
|
||||
if (hoursStr != null) {
|
||||
int hours = Integer.parseInt(hoursStr.replace("h", ""));
|
||||
result = result.plusHours(hours * multiplier);
|
||||
}
|
||||
|
||||
if (minutesStr != null) {
|
||||
int minutes = Integer.parseInt(minutesStr.replace("m", ""));
|
||||
result = result.plusMinutes(minutes * multiplier);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a LocalDateTime according to the specified format.
|
||||
* Supported formats:
|
||||
* - null or "iso" - ISO_LOCAL_DATE_TIME (default)
|
||||
* - "rfc3339" - RFC 3339 format with UTC timezone
|
||||
* - custom pattern - Any valid DateTimeFormatter pattern
|
||||
* @param dateTime the LocalDateTime to format
|
||||
* @param format the format specifier
|
||||
* @return formatted date/time string
|
||||
*/
|
||||
private String formatDateTime(LocalDateTime dateTime, String format) {
|
||||
if (format == null || "iso".equals(format)) {
|
||||
return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
}
|
||||
|
||||
try {
|
||||
return switch (format) {
|
||||
case "rfc3339" -> dateTime.atZone(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT);
|
||||
default -> dateTime.format(DateTimeFormatter.ofPattern(format));
|
||||
};
|
||||
} catch (Exception e) {
|
||||
// If format is invalid, fallback to ISO format
|
||||
return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a LocalDate according to the specified format.
|
||||
* Supported formats:
|
||||
* - null or "iso" - ISO_LOCAL_DATE (default)
|
||||
* - "rfc3339" - ISO_LOCAL_DATE (same as ISO for date-only)
|
||||
* - custom pattern - Any valid DateTimeFormatter pattern
|
||||
* @param date the LocalDate to format
|
||||
* @param format the format specifier
|
||||
* @return formatted date string
|
||||
*/
|
||||
private String formatDate(LocalDate date, String format) {
|
||||
if (format == null || "iso".equals(format) || "rfc3339".equals(format)) {
|
||||
return date.format(DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
}
|
||||
|
||||
try {
|
||||
return date.format(DateTimeFormatter.ofPattern(format));
|
||||
} catch (Exception e) {
|
||||
// If format is invalid, fallback to ISO format
|
||||
return date.format(DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to convert a raw string into a typed value if a type hint is provided.
|
||||
* Supported types:
|
||||
|
||||
@ -8,6 +8,10 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -83,4 +87,171 @@ class MockServiceTest {
|
||||
Optional<MockEndpoint> result = mockService.getMatchedEndpoint("POST", "/greet/Alice");
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleTypedPlaceholders() {
|
||||
Map<String, String> pathVars = Map.of(
|
||||
"id", "123",
|
||||
"price", "99.99",
|
||||
"active", "true"
|
||||
);
|
||||
|
||||
// Test int type
|
||||
Object intResult = mockService.replacePlaceholders("{id:int}", pathVars);
|
||||
assertInstanceOf(Integer.class, intResult);
|
||||
assertEquals(123, intResult);
|
||||
|
||||
// Test float type
|
||||
Object floatResult = mockService.replacePlaceholders("{price:float}", pathVars);
|
||||
assertInstanceOf(Double.class, floatResult);
|
||||
assertEquals(99.99, floatResult);
|
||||
|
||||
// Test bool type
|
||||
Object boolResult = mockService.replacePlaceholders("{active:bool}", pathVars);
|
||||
assertInstanceOf(Boolean.class, boolResult);
|
||||
assertEquals(true, boolResult);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleDatetimeOffsetPattern() {
|
||||
// Test datetime with positive offset
|
||||
Object result = mockService.replacePlaceholders("{datetime+2d}", Map.of());
|
||||
assertInstanceOf(String.class, result);
|
||||
|
||||
LocalDateTime expected = LocalDateTime.now().plusDays(2);
|
||||
String expectedStr = expected.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
|
||||
// Compare just the date part since time might differ by milliseconds
|
||||
assertTrue(((String) result).startsWith(expected.format(DateTimeFormatter.ISO_LOCAL_DATE)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleDatetimeOffsetWithHoursAndMinutes() {
|
||||
// Test datetime with days, hours, and minutes
|
||||
Object result = mockService.replacePlaceholders("{datetime+1d2h30m}", Map.of());
|
||||
assertInstanceOf(String.class, result);
|
||||
|
||||
LocalDateTime expected = LocalDateTime.now().plusDays(1).plusHours(2).plusMinutes(30);
|
||||
String expectedStr = expected.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
|
||||
// Compare just the date and hour part
|
||||
assertTrue(((String) result).startsWith(expected.format(DateTimeFormatter.ISO_LOCAL_DATE) + "T"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleDatetimeOffsetWithNegativeOffset() {
|
||||
// Test datetime with negative offset
|
||||
Object result = mockService.replacePlaceholders("{datetime-3d}", Map.of());
|
||||
assertInstanceOf(String.class, result);
|
||||
|
||||
LocalDateTime expected = LocalDateTime.now().minusDays(3);
|
||||
|
||||
// Compare just the date part
|
||||
assertTrue(((String) result).startsWith(expected.format(DateTimeFormatter.ISO_LOCAL_DATE)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleDatetimeAtSpecificTime() {
|
||||
// Test datetime with specific time
|
||||
Object result = mockService.replacePlaceholders("{datetime+2d@14:30}", Map.of());
|
||||
assertInstanceOf(String.class, result);
|
||||
|
||||
LocalDate expectedDate = LocalDate.now().plusDays(2);
|
||||
|
||||
// Check that the result contains the expected date and time
|
||||
assertTrue(((String) result).contains(expectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)));
|
||||
assertTrue(((String) result).contains("14:30"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleDateOffsetPattern() {
|
||||
// Test date with positive offset
|
||||
Object result = mockService.replacePlaceholders("{date+5d}", Map.of());
|
||||
assertInstanceOf(String.class, result);
|
||||
|
||||
LocalDate expected = LocalDate.now().plusDays(5);
|
||||
String expectedStr = expected.format(DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
|
||||
assertEquals(expectedStr, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleDateOffsetWithNegativeOffset() {
|
||||
// Test date with negative offset
|
||||
Object result = mockService.replacePlaceholders("{date-7d}", Map.of());
|
||||
assertInstanceOf(String.class, result);
|
||||
|
||||
LocalDate expected = LocalDate.now().minusDays(7);
|
||||
String expectedStr = expected.format(DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
|
||||
assertEquals(expectedStr, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleDatetimeWithRfc3339Format() {
|
||||
// Test datetime with RFC3339 format
|
||||
Object result = mockService.replacePlaceholders("{datetime+1d:rfc3339}", Map.of());
|
||||
assertInstanceOf(String.class, result);
|
||||
|
||||
// RFC3339 format should end with 'Z' for UTC
|
||||
assertTrue(((String) result).endsWith("Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleDateWithCustomFormat() {
|
||||
// Test date with custom format
|
||||
Object result = mockService.replacePlaceholders("{date+1d:MMM dd, yyyy}", Map.of());
|
||||
assertInstanceOf(String.class, result);
|
||||
|
||||
LocalDate expected = LocalDate.now().plusDays(1);
|
||||
String expectedStr = expected.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"));
|
||||
|
||||
assertEquals(expectedStr, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleDatetimeAtTimeWithFormat() {
|
||||
// Test datetime at specific time with format
|
||||
Object result = mockService.replacePlaceholders("{datetime+1d@09:00:rfc3339}", Map.of());
|
||||
assertInstanceOf(String.class, result);
|
||||
|
||||
// Should be a valid RFC3339 formatted string
|
||||
assertTrue(((String) result).endsWith("Z"));
|
||||
assertTrue(((String) result).contains("09:00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleInlinePlaceholderReplacement() {
|
||||
Map<String, String> pathVars = Map.of("name", "Alice", "age", "25");
|
||||
|
||||
Object result = mockService.replacePlaceholders(
|
||||
"User {name} is {age:int} years old",
|
||||
pathVars
|
||||
);
|
||||
|
||||
assertInstanceOf(String.class, result);
|
||||
assertEquals("User Alice is 25 years old", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleMixedPlaceholders() {
|
||||
Map<String, String> pathVars = Map.of("userId", "42");
|
||||
|
||||
LocalDate expectedDate = LocalDate.now().plusDays(1);
|
||||
String expectedDateStr = expectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
|
||||
Object result = mockService.replacePlaceholders(
|
||||
Map.of(
|
||||
"userId", "{userId:int}",
|
||||
"expiryDate", "{date+1d}"
|
||||
),
|
||||
pathVars
|
||||
);
|
||||
|
||||
assertInstanceOf(Map.class, result);
|
||||
Map<?, ?> resultMap = (Map<?, ?>) result;
|
||||
|
||||
assertEquals(42, resultMap.get("userId"));
|
||||
assertEquals(expectedDateStr, resultMap.get("expiryDate"));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user