-
Notifications
You must be signed in to change notification settings - Fork 202
Fix: Handle trailing slashes in server URLs across all libraries #1969
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
d6c5935
61c1c78
33f3feb
93850d7
e6fe12d
868d942
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -109,7 +109,10 @@ export class SvixRequest { | |
} | ||
|
||
private async sendInner(ctx: SvixRequestContext): Promise<Response> { | ||
const url = new URL(ctx.baseUrl + this.path); | ||
// Ensure path always starts with a single slash | ||
const normalizedPath = this.path.replace(/^\/+/, "/"); | ||
Comment on lines
+112
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do this, and only for JavaScript? This is an internal API, and I'm pretty sure it's only ever called with paths that already begin with (one) slash, I think? |
||
// Combine normalized base URL and path | ||
const url = new URL(ctx.baseUrl + normalizedPath); | ||
for (const [name, value] of Object.entries(this.queryParams)) { | ||
url.searchParams.set(name, value); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import { Svix } from "./index"; | ||
import * as mockttp from "mockttp"; | ||
|
||
const mockServer = mockttp.getLocal(); | ||
|
||
describe("Trailing Slash Handling", () => { | ||
beforeEach(async () => await mockServer.start(0)); | ||
afterEach(async () => await mockServer.stop()); | ||
|
||
const testEndpoints = [ | ||
"/api/v1/app", | ||
"/api/v1/app/app_id/endpoint", | ||
"/ingest/api/v1/source/source_id", | ||
"/api/v1/operational-webhook/endpoint", | ||
]; | ||
|
||
test.each(testEndpoints)("should handle trailing slash variations for %s", async () => { | ||
// Test with trailing slash in base URL | ||
const clientWithSlash = new Svix("token", { serverUrl: mockServer.url + "/" }); | ||
|
||
// Test without trailing slash in base URL | ||
const clientWithoutSlash = new Svix("token", { serverUrl: mockServer.url }); | ||
|
||
// Make requests using internal request context | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const reqCtx1 = (clientWithSlash as any).requestCtx; | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const reqCtx2 = (clientWithoutSlash as any).requestCtx; | ||
|
||
// Verify both clients have normalized base URLs (no trailing slash) | ||
expect(reqCtx1.baseUrl).not.toMatch(/\/$/); | ||
expect(reqCtx2.baseUrl).not.toMatch(/\/$/); | ||
expect(reqCtx1.baseUrl).toBe(reqCtx2.baseUrl); | ||
}); | ||
|
||
test("should handle multiple trailing slashes", () => { | ||
const client = new Svix("token", { serverUrl: "https://api.svix.com///" }); | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const reqCtx = (client as any).requestCtx; | ||
expect(reqCtx.baseUrl).toBe("https://api.svix.com"); | ||
}); | ||
|
||
test("should handle single trailing slash", () => { | ||
const client = new Svix("token", { serverUrl: "https://api.svix.com/" }); | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const reqCtx = (client as any).requestCtx; | ||
expect(reqCtx.baseUrl).toBe("https://api.svix.com"); | ||
}); | ||
|
||
test("should preserve URLs without trailing slashes", () => { | ||
const client = new Svix("token", { serverUrl: "https://api.svix.com" }); | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const reqCtx = (client as any).requestCtx; | ||
expect(reqCtx.baseUrl).toBe("https://api.svix.com"); | ||
}); | ||
|
||
test("should handle regional URLs with trailing slashes", () => { | ||
// Mock a token with EU region | ||
const client = new Svix("test.eu.token"); | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const reqCtx = (client as any).requestCtx; | ||
expect(reqCtx.baseUrl).toBe("https://api.eu.svix.com"); | ||
}); | ||
}); |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you add a final newline to the end of this file? |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import pytest | ||
from svix.api import Svix, SvixOptions | ||
|
||
|
||
def test_trailing_slash_handling(): | ||
"""Test that trailing slashes are properly removed from server URLs.""" | ||
|
||
# Test with trailing slash | ||
svix_with_slash = Svix("key", SvixOptions(server_url="http://localhost:8000/")) | ||
assert svix_with_slash._client.base_url == "http://localhost:8000" | ||
|
||
# Test without trailing slash | ||
svix_without_slash = Svix("key", SvixOptions(server_url="http://localhost:8000")) | ||
assert svix_without_slash._client.base_url == "http://localhost:8000" | ||
|
||
# Test with multiple trailing slashes | ||
svix_multiple_slashes = Svix("key", SvixOptions(server_url="http://localhost:8000///")) | ||
assert svix_multiple_slashes._client.base_url == "http://localhost:8000" | ||
|
||
|
||
def test_regional_url_trailing_slash(): | ||
"""Test that regional URLs are handled correctly.""" | ||
|
||
# Test EU region token (regional URL should not have trailing slash) | ||
svix_eu = Svix("test.eu.token") | ||
assert svix_eu._client.base_url == "https://api.eu.svix.com" | ||
|
||
# Test US region token | ||
svix_us = Svix("test.us.token") | ||
assert svix_us._client.base_url == "https://api.us.svix.com" | ||
|
||
|
||
def test_default_url_no_trailing_slash(): | ||
"""Test that default URL doesn't have trailing slash.""" | ||
|
||
svix_default = Svix("key") | ||
assert svix_default._client.base_url == "https://api.svix.com" |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
RSpec.describe "Trailing Slash Handling" do | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm surprised you copy-pasted bits from the client initialization here rather than actually calling the client initialization. This way the test could easily get out of sync with the actual code. This being Ruby, should it not be simple to check the internal state of the client after construction, like you did with Python? (I haven't seriously used Ruby, but I know it allows all sorts of monkey patching) |
||
describe "URL normalization logic" do | ||
it "removes single trailing slash" do | ||
url = "https://api.svix.com/" | ||
result = url.sub(/\/+$/, '') | ||
expect(result).to eq("https://api.svix.com") | ||
end | ||
|
||
it "removes multiple trailing slashes" do | ||
url = "https://api.svix.com///" | ||
result = url.sub(/\/+$/, '') | ||
expect(result).to eq("https://api.svix.com") | ||
end | ||
|
||
it "preserves URLs without trailing slashes" do | ||
url = "https://api.svix.com" | ||
result = url.sub(/\/+$/, '') | ||
expect(result).to eq("https://api.svix.com") | ||
end | ||
|
||
it "handles localhost URLs with trailing slash" do | ||
url = "http://localhost:8000/" | ||
result = url.sub(/\/+$/, '') | ||
expect(result).to eq("http://localhost:8000") | ||
end | ||
|
||
it "handles localhost URLs with multiple trailing slashes" do | ||
url = "http://localhost:8000///" | ||
result = url.sub(/\/+$/, '') | ||
expect(result).to eq("http://localhost:8000") | ||
end | ||
end | ||
|
||
describe "regional URL logic" do | ||
it "detects EU region from token" do | ||
token = "test.eu.token" | ||
region = token.split('.')[1] | ||
expected_url = case region | ||
when "us" then "https://api.us.svix.com" | ||
when "eu" then "https://api.eu.svix.com" | ||
when "in" then "https://api.in.svix.com" | ||
when "ca" then "https://api.ca.svix.com" | ||
when "au" then "https://api.au.svix.com" | ||
else "https://api.svix.com" | ||
end | ||
|
||
expect(expected_url).to eq("https://api.eu.svix.com") | ||
end | ||
|
||
it "defaults to main API for unknown regions" do | ||
token = "test.unknown.token" | ||
region = token.split('.')[1] | ||
expected_url = case region | ||
when "us" then "https://api.us.svix.com" | ||
when "eu" then "https://api.eu.svix.com" | ||
when "in" then "https://api.in.svix.com" | ||
when "ca" then "https://api.ca.svix.com" | ||
when "au" then "https://api.au.svix.com" | ||
else "https://api.svix.com" | ||
end | ||
|
||
expect(expected_url).to eq("https://api.svix.com") | ||
end | ||
end | ||
|
||
describe "combined logic test" do | ||
it "processes URLs with trailing slashes and regional detection" do | ||
test_cases = [ | ||
{ input: "https://api.svix.com/", expected: "https://api.svix.com" }, | ||
{ input: "https://api.eu.svix.com///", expected: "https://api.eu.svix.com" }, | ||
{ input: "http://localhost:8000/", expected: "http://localhost:8000" } | ||
] | ||
|
||
test_cases.each do |test_case| | ||
result = test_case[:input].sub(/\/+$/, '') | ||
expect(result).to eq(test_case[:expected]) | ||
end | ||
end | ||
end | ||
end |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you make this a unit test rather than an integration test, you can make assertions about the internal state of the #[cfg(test)]
mod tests {
// write tests here
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,62 @@ | ||||||
#[test] | ||||||
fn test_trailing_slash_removal_logic() { | ||||||
let test_cases = vec![ | ||||||
("https://api.svix.com/", "https://api.svix.com"), | ||||||
("https://api.svix.com///", "https://api.svix.com"), | ||||||
("https://api.svix.com", "https://api.svix.com"), | ||||||
("http://localhost:8000/", "http://localhost:8000"), | ||||||
("http://localhost:8000///", "http://localhost:8000"), | ||||||
]; | ||||||
|
||||||
for (input, expected) in test_cases { | ||||||
let mut result = input.to_string(); | ||||||
while result.ends_with('/') { | ||||||
result.pop(); | ||||||
} | ||||||
|
||||||
assert_eq!(result, expected, "Failed for input: {}", input); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
println!("✅ {} → {}", input, result); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. stdout is only printed if a test fails, so this seems kind of pointless. |
||||||
} | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_trailing_slash_edge_cases() { | ||||||
// Test edge cases | ||||||
let mut url = String::from("https://api.svix.com/////"); | ||||||
while url.ends_with('/') { | ||||||
url.pop(); | ||||||
} | ||||||
assert_eq!(url, "https://api.svix.com"); | ||||||
|
||||||
let mut url2 = String::from("https://api.svix.com"); | ||||||
while url2.ends_with('/') { | ||||||
url2.pop(); | ||||||
} | ||||||
assert_eq!(url2, "https://api.svix.com"); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_regional_url_logic() { | ||||||
// Test regional URL detection logic | ||||||
let tokens = vec![ | ||||||
("test.eu.token", "https://api.eu.svix.com"), | ||||||
("test.us.token", "https://api.us.svix.com"), | ||||||
("test.ca.token", "https://api.ca.svix.com"), | ||||||
("regular.token", "https://api.svix.com"), | ||||||
]; | ||||||
|
||||||
for (token, expected_base) in tokens { | ||||||
let region = token.split('.').nth(1); | ||||||
let base_url = match region { | ||||||
Some("us") => "https://api.us.svix.com", | ||||||
Some("eu") => "https://api.eu.svix.com", | ||||||
Some("in") => "https://api.in.svix.com", | ||||||
Some("ca") => "https://api.ca.svix.com", | ||||||
Some("au") => "https://api.au.svix.com", | ||||||
_ => "https://api.svix.com", | ||||||
}; | ||||||
|
||||||
assert_eq!(base_url, expected_base, "Failed for token: {}", token); | ||||||
println!("✅ {} → {}", token, base_url); | ||||||
} | ||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's cleaner to only run the
replace
if the user specified a server URL. The fallback values all don't end in/
and I wouldn't want us to become sloppy with things like that internally.Same for all the other SDKs.