Add relative time and date #6

Merged
stne3960 merged 5 commits from relative_dates into main 2025-11-03 23:38:56 +01:00
3 changed files with 425 additions and 5 deletions

View File

@ -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

View File

@ -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:

View File

@ -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"));
}
}