Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export class Svix {

public constructor(token: string, options: SvixOptions = {}) {
const regionalUrl = REGIONS.find((x) => x.region === token.split(".")[1])?.url;
const baseUrl: string = options.serverUrl ?? regionalUrl ?? "https://api.svix.com";
const baseUrl: string = (
options.serverUrl ??
regionalUrl ??
"https://api.svix.com"
).replace(/\/+$/, "");
Comment on lines +52 to +56
Copy link
Member

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.


this.requestCtx = { baseUrl, token, timeout: options.requestTimeout };
}
Expand Down
5 changes: 4 additions & 1 deletion javascript/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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);
}
Expand Down
64 changes: 64 additions & 0 deletions javascript/src/trailing-slash.test.ts
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");
});
});
2 changes: 1 addition & 1 deletion python/svix/api/svix.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def __init__(self, auth_token: str, options: SvixOptions = SvixOptions()) -> Non
elif region == "au":
regional_url = "https://api.au.svix.com"

host = options.server_url or regional_url or DEFAULT_SERVER_URL
host = (options.server_url or regional_url or DEFAULT_SERVER_URL).rstrip('/')
client = AuthenticatedClient(
base_url=host,
token=auth_token,
Expand Down
37 changes: 37 additions & 0 deletions python/tests/test_trailing_slash.py
Copy link
Member

Choose a reason for hiding this comment

The 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"
3 changes: 2 additions & 1 deletion ruby/lib/svix/svix.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ def initialize(auth_token, options = SvixOptions.new)
regional_url = "https://api.svix.com"
end

uri = URI(options.server_url || regional_url)
server_url = (options.server_url || regional_url).sub(/\/+$/, '')
uri = URI(server_url)
api_client = SvixHttpClient.new(auth_token, uri)

@application = Application.new(api_client)
Expand Down
80 changes: 80 additions & 0 deletions ruby/spec/trailing_slash_spec.rb
Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link
Member

Choose a reason for hiding this comment

The 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
6 changes: 5 additions & 1 deletion rust/src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ impl Svix {
/// This can be used to change the token without incurring
/// the cost of TLS initialization.
pub fn with_token(&self, token: String) -> Self {
let base_path = self.server_url.clone().unwrap_or_else(|| {
let mut base_path = self.server_url.clone().unwrap_or_else(|| {
match token.split('.').next_back() {
Some("us") => "https://api.us.svix.com",
Some("eu") => "https://api.eu.svix.com",
Expand All @@ -82,6 +82,10 @@ impl Svix {
}
.to_string()
});
// Remove trailing slashes
while base_path.ends_with('/') {
base_path.pop();
}
let cfg = Arc::new(Configuration {
base_path,
user_agent: self.cfg.user_agent.clone(),
Expand Down
62 changes: 62 additions & 0 deletions rust/tests/trailing_slash_test.rs
Copy link
Member

Choose a reason for hiding this comment

The 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 Svix client. The way it works is you create an inline module within src/api/client.rs like

#[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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert_eq!(result, expected, "Failed for input: {}", input);
assert_eq!(result, expected, "Failed for input: {input}");

println!("✅ {} → {}", input, result);
Copy link
Member

Choose a reason for hiding this comment

The 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);
}
}