@schmock/core 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"interceptor.d.ts","sourceRoot":"","sources":["../src/interceptor.ts"],"names":[],"mappings":"AAoHA;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,CACN,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,cAAc,CAAC,EAAE,OAAO,CAAC,cAAc,KACpC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,EAC9B,OAAO,GAAE,OAAO,CAAC,gBAAqB,GACrC,OAAO,CAAC,eAAe,CAiJzB"}
1
+ {"version":3,"file":"interceptor.d.ts","sourceRoot":"","sources":["../src/interceptor.ts"],"names":[],"mappings":"AA2JA;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,CACN,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,cAAc,CAAC,EAAE,OAAO,CAAC,cAAc,KACpC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,EAC9B,OAAO,GAAE,OAAO,CAAC,gBAAqB,GACrC,OAAO,CAAC,eAAe,CA0JzB"}
@@ -19,6 +19,43 @@ function extractPathname(url) {
19
19
  }
20
20
  return urlWithoutQuery;
21
21
  }
22
+ /**
23
+ * Extract origin (scheme + host + port) from a URL string, or null for
24
+ * relative URLs that don't carry one.
25
+ */
26
+ function extractOrigin(url) {
27
+ if (!url.includes("://"))
28
+ return null;
29
+ try {
30
+ return new URL(url).origin;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /**
37
+ * Parse the user-supplied baseUrl option into its origin and path parts.
38
+ * - "/api" → { origin: null, path: "/api" }
39
+ * - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
40
+ * - "https://x.com" → { origin: "https://x.com", path: "" }
41
+ *
42
+ * Trailing slash is stripped from the path so the segment-boundary check
43
+ * works the same way for "/api" and "/api/".
44
+ */
45
+ function parseBaseUrl(baseUrl) {
46
+ if (baseUrl.includes("://")) {
47
+ try {
48
+ const parsed = new URL(baseUrl);
49
+ const rawPath = parsed.pathname;
50
+ const path = rawPath === "/" ? "" : rawPath.replace(/\/$/, "");
51
+ return { origin: parsed.origin, path };
52
+ }
53
+ catch {
54
+ // Fall through to path-only handling
55
+ }
56
+ }
57
+ return { origin: null, path: baseUrl.replace(/\/$/, "") };
58
+ }
22
59
  /**
23
60
  * Extract query parameters from a URL string.
24
61
  */
@@ -114,15 +151,25 @@ export function createFetchInterceptor(handle, options = {}) {
114
151
  ? input.href
115
152
  : input;
116
153
  const path = extractPathname(urlString);
117
- // BaseUrl filter — non-matching requests go straight to real fetch
118
- // Enforce segment boundary: /api must not match /apiv2
154
+ // BaseUrl filter — non-matching requests go straight to real fetch.
155
+ // Two modes:
156
+ // - origin form ("https://api.example.com/v1"): require matching
157
+ // origin AND matching path prefix.
158
+ // - path form ("/api"): match pathname prefix only.
159
+ // Both enforce a segment boundary so "/api" doesn't match "/apiv2".
119
160
  if (baseUrl) {
120
- const normalizedBase = baseUrl.endsWith("/")
121
- ? baseUrl.slice(0, -1)
122
- : baseUrl;
123
- const isMatch = path === normalizedBase || path.startsWith(`${normalizedBase}/`);
124
- if (!isMatch) {
125
- return originalFetch(input, init);
161
+ const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
162
+ if (baseOrigin) {
163
+ const reqOrigin = extractOrigin(urlString);
164
+ if (reqOrigin !== baseOrigin) {
165
+ return originalFetch(input, init);
166
+ }
167
+ }
168
+ if (basePath) {
169
+ const isMatch = path === basePath || path.startsWith(`${basePath}/`);
170
+ if (!isMatch) {
171
+ return originalFetch(input, init);
172
+ }
126
173
  }
127
174
  }
128
175
  // Build adapter request
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schmock/core",
3
3
  "description": "Core functionality for Schmock",
4
- "version": "2.0.3",
4
+ "version": "2.1.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -24,6 +24,45 @@ function extractPathname(url: string): string {
24
24
  return urlWithoutQuery;
25
25
  }
26
26
 
27
+ /**
28
+ * Extract origin (scheme + host + port) from a URL string, or null for
29
+ * relative URLs that don't carry one.
30
+ */
31
+ function extractOrigin(url: string): string | null {
32
+ if (!url.includes("://")) return null;
33
+ try {
34
+ return new URL(url).origin;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Parse the user-supplied baseUrl option into its origin and path parts.
42
+ * - "/api" → { origin: null, path: "/api" }
43
+ * - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
44
+ * - "https://x.com" → { origin: "https://x.com", path: "" }
45
+ *
46
+ * Trailing slash is stripped from the path so the segment-boundary check
47
+ * works the same way for "/api" and "/api/".
48
+ */
49
+ function parseBaseUrl(baseUrl: string): {
50
+ origin: string | null;
51
+ path: string;
52
+ } {
53
+ if (baseUrl.includes("://")) {
54
+ try {
55
+ const parsed = new URL(baseUrl);
56
+ const rawPath = parsed.pathname;
57
+ const path = rawPath === "/" ? "" : rawPath.replace(/\/$/, "");
58
+ return { origin: parsed.origin, path };
59
+ } catch {
60
+ // Fall through to path-only handling
61
+ }
62
+ }
63
+ return { origin: null, path: baseUrl.replace(/\/$/, "") };
64
+ }
65
+
27
66
  /**
28
67
  * Extract query parameters from a URL string.
29
68
  */
@@ -150,16 +189,25 @@ export function createFetchInterceptor(
150
189
 
151
190
  const path = extractPathname(urlString);
152
191
 
153
- // BaseUrl filter — non-matching requests go straight to real fetch
154
- // Enforce segment boundary: /api must not match /apiv2
192
+ // BaseUrl filter — non-matching requests go straight to real fetch.
193
+ // Two modes:
194
+ // - origin form ("https://api.example.com/v1"): require matching
195
+ // origin AND matching path prefix.
196
+ // - path form ("/api"): match pathname prefix only.
197
+ // Both enforce a segment boundary so "/api" doesn't match "/apiv2".
155
198
  if (baseUrl) {
156
- const normalizedBase = baseUrl.endsWith("/")
157
- ? baseUrl.slice(0, -1)
158
- : baseUrl;
159
- const isMatch =
160
- path === normalizedBase || path.startsWith(`${normalizedBase}/`);
161
- if (!isMatch) {
162
- return originalFetch(input, init);
199
+ const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
200
+ if (baseOrigin) {
201
+ const reqOrigin = extractOrigin(urlString);
202
+ if (reqOrigin !== baseOrigin) {
203
+ return originalFetch(input, init);
204
+ }
205
+ }
206
+ if (basePath) {
207
+ const isMatch = path === basePath || path.startsWith(`${basePath}/`);
208
+ if (!isMatch) {
209
+ return originalFetch(input, init);
210
+ }
163
211
  }
164
212
  }
165
213
 
@@ -130,6 +130,60 @@ describeFeature(feature, ({ Scenario, AfterEachScenario }) => {
130
130
  });
131
131
  });
132
132
 
133
+ Scenario("Origin-form baseUrl intercepts matching origin only", ({ Given, When, Then, And }) => {
134
+ Given("a Schmock instance with route \"GET /api/users\" returning users", () => {
135
+ setup();
136
+ mock("GET /api/users", [{ id: 1 }]);
137
+ });
138
+
139
+ And("fetch is intercepted with baseUrl \"https://api.example.com\"", () => {
140
+ handle = mock.intercept({ baseUrl: "https://api.example.com" });
141
+ });
142
+
143
+ When("I fetch \"https://api.example.com/api/users\"", async () => {
144
+ fetchResponse = await fetch("https://api.example.com/api/users");
145
+ });
146
+
147
+ Then("the response status should be {int}", (_, status: number) => {
148
+ expect(fetchResponse?.status).toBe(status);
149
+ });
150
+
151
+ When("I fetch \"https://other.example.com/api/users\"", async () => {
152
+ await fetch("https://other.example.com/api/users");
153
+ });
154
+
155
+ Then("the original fetch should have been called", () => {
156
+ expect(savedFetch).toHaveBeenCalled();
157
+ });
158
+ });
159
+
160
+ Scenario("Origin-form baseUrl with a path prefix requires both to match", ({ Given, When, Then, And }) => {
161
+ Given("a Schmock instance with route \"GET /v1/users\" returning users", () => {
162
+ setup();
163
+ mock("GET /v1/users", [{ id: 1 }]);
164
+ });
165
+
166
+ And("fetch is intercepted with baseUrl \"https://api.example.com/v1\"", () => {
167
+ handle = mock.intercept({ baseUrl: "https://api.example.com/v1" });
168
+ });
169
+
170
+ When("I fetch \"https://api.example.com/v1/users\"", async () => {
171
+ fetchResponse = await fetch("https://api.example.com/v1/users");
172
+ });
173
+
174
+ Then("the response status should be {int}", (_, status: number) => {
175
+ expect(fetchResponse?.status).toBe(status);
176
+ });
177
+
178
+ When("I fetch \"https://api.example.com/users\"", async () => {
179
+ await fetch("https://api.example.com/users");
180
+ });
181
+
182
+ Then("the original fetch should have been called", () => {
183
+ expect(savedFetch).toHaveBeenCalled();
184
+ });
185
+ });
186
+
133
187
  Scenario("beforeRequest hook modifies the request", ({ Given, When, Then, And }) => {
134
188
  Given("a Schmock instance with route \"GET /api/users\" that reads headers", () => {
135
189
  setup();