diff --git a/CHANGELOG.md b/CHANGELOG.md index cf912b3..680a60a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. +## [1.0.2] - 2026-05-03 +### Added +- URL path prefix mapping for cross-environment replay: record on staging (e.g. `/microservice1/api/users`), replay locally (e.g. `/api/users`) using the new **URL Mappings** field in the Replay tab. +- Mappings are persisted with each recording's replay options and restored on selection. +- README workflow documentation for the central-record / local-replay use case. + ## [1.0.1] - 2026-04-26 ### Added diff --git a/README.md b/README.md index 907893c..b180c54 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ It is built for a low-overhead workflow: record once, export JSON fixtures, and ![API Replay popup preview](img.png) +![API Replay overview — before and after](apireplay-overview.png) + ## Why teams use it - Keep working when central APIs are slow, down, or changing. @@ -39,7 +41,19 @@ Tip: Use `Alt+Shift+R` to toggle recording quickly. ## Common workflows -### A) Record in shared environment, replay locally +### A) Record in shared environment, replay locally (with URL path mapping) + +A common use case is recording on a central/staging environment where APIs are served under microservice-prefixed paths (e.g. `/microservice1/api/users`), then replaying locally where the same API is served at a shorter path (e.g. `/api/users`). + +To handle this: +1. Record normally on the central environment — requests are stored as-is. +2. Before starting replay locally, enter a URL mapping in the **URL Mappings** field in the Replay tab: + ``` + /microservice1/api -> /api + ``` +3. During replay, incoming requests to `/api/users` will be matched against the recorded `/microservice1/api/users` entry. + +Multiple mappings are supported (one per line). The first matching prefix wins. Mappings are saved with the recording's replay options and restored when you select the recording again. Capture traffic from a central/staging environment, export the recording JSON, commit it, and reuse it locally or in CI. @@ -66,6 +80,7 @@ API Replay currently documents and supports client/network request mocking flows - Search requests by URL/method/status. - Enable/disable replay per request. - Simulate replay latency (`latencyMs` or range). +- Map recorded URL path prefixes to different prefixes during replay (e.g. `/microservice1/api` → `/api`). - View replay stats (matched/unmatched/hit counts). ## Development commands diff --git a/apireplay-overview.png b/apireplay-overview.png new file mode 100644 index 0000000..1a484c3 Binary files /dev/null and b/apireplay-overview.png differ diff --git a/package.json b/package.json index 510ce9a..aa7e5ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apireplay", - "version": "1.0.1", + "version": "1.0.2", "private": true, "type": "module", "scripts": { diff --git a/src/background/replayer.ts b/src/background/replayer.ts index abd84ea..7d89c4a 100644 --- a/src/background/replayer.ts +++ b/src/background/replayer.ts @@ -1,10 +1,25 @@ import type { StateStore } from './state-store'; import { getRecordingByName } from '../shared/storage'; +import type { UrlMapping } from '../shared/recording'; function getPathname(url: string): string { return new URL(url).pathname; } +function applyUrlMappings(pathname: string, mappings: UrlMapping[] | undefined): string { + if (!mappings || mappings.length === 0) { + return pathname; + } + for (const mapping of mappings) { + const from = mapping.from.startsWith('/') ? mapping.from : '/' + mapping.from; + const to = mapping.to.startsWith('/') ? mapping.to : '/' + mapping.to; + if (pathname.startsWith(from)) { + return to + pathname.slice(from.length); + } + } + return pathname; +} + type ReplayerEventParams = { request?: { url?: string }; interceptionId?: string; @@ -156,13 +171,17 @@ export async function onReplayerEvent(store: StateStore, tabId: number, message: return; } - let matched = requestValues.find((item) => getPathname(item.url) === getPathname(requestUrl)); + const replayOptionsData = await chrome.storage.session.get('replayOptions'); + const urlMappings = replayOptionsData.replayOptions?.urlMappings; + + const incomingPathname = applyUrlMappings(getPathname(requestUrl), urlMappings); + + let matched = requestValues.find((item) => getPathname(item.url) === incomingPathname); if (!matched && state.fallbackMatchingEnabled) { matched = requestValues.find((item) => { const candidatePath = getPathname(item.url); - const targetPath = getPathname(requestUrl); - return candidatePath.split('/').filter(Boolean).length === targetPath.split('/').filter(Boolean).length; + return candidatePath.split('/').filter(Boolean).length === incomingPathname.split('/').filter(Boolean).length; }); } @@ -224,4 +243,4 @@ export async function onReplayerEvent(store: StateStore, tabId: number, message: ) }); } -} \ No newline at end of file +} diff --git a/src/popup.html b/src/popup.html index 4344b1d..fbff9bf 100644 --- a/src/popup.html +++ b/src/popup.html @@ -107,6 +107,10 @@

Select Recording

+
+ + +
diff --git a/src/popup/main.ts b/src/popup/main.ts index 00c3233..440a946 100644 --- a/src/popup/main.ts +++ b/src/popup/main.ts @@ -49,6 +49,7 @@ document.addEventListener('DOMContentLoaded', () => { const replayStatsPanel = document.getElementById('replayStatsPanel'); const latencyMsInput = document.getElementById('latencyMs'); const latencyRangeInput = document.getElementById('latencyRange'); + const urlMappingsInput = document.getElementById('urlMappings') as HTMLTextAreaElement | null; const recordingSelect = document.getElementById('recordingSelect'); const deleteRecordBtn = document.getElementById('deleteRecord'); const removeAllRecordingsBtn = document.getElementById('removeAllRecordings'); @@ -233,9 +234,19 @@ document.addEventListener('DOMContentLoaded', () => { .split(',') .map((entry) => Number(entry.trim())) .filter((entry) => Number.isFinite(entry)); + const urlMappings = (urlMappingsInput?.value || '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.includes('->')) + .map((line) => { + const [from, to] = line.split('->').map((s) => s.trim()); + return { from, to }; + }) + .filter((m) => m.from && m.to); const options = { latencyMs: latencyMsValue > 0 ? latencyMsValue : undefined, - latencyRange: latencyRangeValue.length === 2 ? [latencyRangeValue[0], latencyRangeValue[1]] : undefined + latencyRange: latencyRangeValue.length === 2 ? [latencyRangeValue[0], latencyRangeValue[1]] : undefined, + urlMappings: urlMappings.length > 0 ? urlMappings : undefined }; void updateReplayOptions(name, options); chrome.runtime.sendMessage({ action: 'startReplaying', name, fallbackMatching, options }, (response) => { @@ -309,6 +320,7 @@ document.addEventListener('DOMContentLoaded', () => { recordingSelect.addEventListener('input', () => { updateButtonStates(); updateApiPreview(); + loadReplayOptionsIntoUI(recordingSelect.value); chrome.storage.local.set({ 'lastUsedRecord': recordingSelect.value }); }); @@ -483,6 +495,18 @@ document.addEventListener('DOMContentLoaded', () => { ); } + function loadReplayOptionsIntoUI(name: string) { + if (!name) return; + void getRecording(name).then((recording) => { + if (!recording) return; + const opts = recording.replayOptions; + if (latencyMsInput) (latencyMsInput as HTMLInputElement).value = opts?.latencyMs != null ? String(opts.latencyMs) : ''; + if (latencyRangeInput) (latencyRangeInput as HTMLInputElement).value = opts?.latencyRange ? opts.latencyRange.join(',') : ''; + if (fallbackMatchingCheckbox) (fallbackMatchingCheckbox as HTMLInputElement).checked = opts?.fallbackMatching ?? false; + if (urlMappingsInput) urlMappingsInput.value = opts?.urlMappings ? opts.urlMappings.map((m) => `${m.from} -> ${m.to}`).join('\n') : ''; + }); + } + function updateApiPreview() { const name = recordingSelect.value; if (name) { diff --git a/src/popup/services/storage.ts b/src/popup/services/storage.ts index 543fdd3..a2bfb91 100644 --- a/src/popup/services/storage.ts +++ b/src/popup/services/storage.ts @@ -1,5 +1,5 @@ import { normalizeRecording } from '../../shared/schema'; -import type { Preset, Recording, UserSettings } from '../../shared/recording'; +import type { Preset, Recording, UserSettings, UrlMapping } from '../../shared/recording'; import { getRecordingByName, getRecordingsStore, @@ -245,7 +245,7 @@ export async function updateRecordingRequestSettings( export async function updateReplayOptions( recordingName: string, - options: { latencyMs?: number; latencyRange?: [number, number] } + options: { latencyMs?: number; latencyRange?: [number, number]; urlMappings?: UrlMapping[] } ): Promise { const recording = await getRecordingByName(recordingName); if (!recording) { diff --git a/src/shared/recording.ts b/src/shared/recording.ts index 1a1c188..83b9b21 100644 --- a/src/shared/recording.ts +++ b/src/shared/recording.ts @@ -14,10 +14,16 @@ export interface RecordedRequest { enabled?: boolean; } +export interface UrlMapping { + from: string; + to: string; +} + export interface ReplayOptions { fallbackMatching?: boolean; latencyMs?: number; latencyRange?: [number, number]; + urlMappings?: UrlMapping[]; } export interface RecordingMetadata {