Repository: svix/svix-webhooks (language: rust)

Issue:
Support URLs without trailing slashes?
## Feature Request

_Opening an issue as requested by @tasn in <https://github.com/svix/svix-webhooks/discussions/1252>_.

Svix API endpoints all have a trailing slash, for example `/api/v1/app/{app_id}/msg/`.

### Motivation

- It's valid, but it's not common in my experience.
- It also caught us twice while we were playing around with the API via Postman and hand-written code where we didn't include it out of habit and got 404s back.
- Some tools don't support trailing slashes; we used Microsoft's Kiota to generate a .NET API client, and during generation it removed the trailing slashes (see <https://github.com/microsoft/kiota/issues/4291>).

### Proposal

The proposal is to:

- Support URLs both with and without the trailing slashes so it's a backwards-compatible change.
- Make URLs without the trailing slash "default", e.g. they should be the ones that appear in the OpenAPI document.

### Alternatives

N/A



Discussion / hints:
@svix/engineering, is where any reason why not just support both?

Fix patch:
diff --git a/server/openapi.json b/server/openapi.json
index a55d11839..80e014af0 100644
--- a/server/openapi.json
+++ b/server/openapi.json
@@ -2040,7 +2040,7 @@
     },
     "openapi": "3.0.2",
     "paths": {
-        "/api/v1/app/": {
+        "/api/v1/app": {
             "get": {
                 "description": "List of all the organization's applications.",
                 "operationId": "v1.application.list",
@@ -2281,7 +2281,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/": {
+        "/api/v1/app/{app_id}": {
             "delete": {
                 "description": "Delete an application.",
                 "operationId": "v1.application.delete",
@@ -2641,7 +2641,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/attempt/endpoint/{endpoint_id}/": {
+        "/api/v1/app/{app_id}/attempt/endpoint/{endpoint_id}": {
             "get": {
                 "description": "List attempts by endpoint id",
                 "operationId": "v1.message-attempt.list-by-endpoint",
@@ -2864,7 +2864,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/attempt/msg/{msg_id}/": {
+        "/api/v1/app/{app_id}/attempt/msg/{msg_id}": {
             "get": {
                 "description": "List attempts by message id",
                 "operationId": "v1.message-attempt.list-by-msg",
@@ -3102,7 +3102,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/endpoint/": {
+        "/api/v1/app/{app_id}/endpoint": {
             "get": {
                 "description": "List the application's endpoints.",
                 "operationId": "v1.endpoint.list",
@@ -3348,7 +3348,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/": {
+        "/api/v1/app/{app_id}/endpoint/{endpoint_id}": {
             "delete": {
                 "description": "Delete an endpoint.",
                 "operationId": "v1.endpoint.delete",
@@ -3805,7 +3805,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/headers/": {
+        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/headers": {
             "get": {
                 "description": "Get the additional headers to be sent with the webhook",
                 "operationId": "v1.endpoint.get-headers",
@@ -4137,7 +4137,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/msg/": {
+        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/msg": {
             "get": {
                 "description": "List messages for a particular endpoint. Additionally includes metadata about the latest message attempt.\n\nThe `before` parameter lets you filter all items created before a certain date and is ignored if an iterator is passed.",
                 "operationId": "v1.message-attempt.list-attempted-messages",
@@ -4349,7 +4349,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/recover/": {
+        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/recover": {
             "post": {
                 "description": "Resend all failed messages since a given time.",
                 "operationId": "v1.endpoint.recover",
@@ -4471,7 +4471,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/secret/": {
+        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/secret": {
             "get": {
                 "description": "Get the endpoint's signing secret.\n\nThis is used to verify the authenticity of the webhook.\nFor more information please refer to [the consuming webhooks docs](https://docs.svix.com/consuming-webhooks/).",
                 "operationId": "v1.endpoint.get-secret",
@@ -4581,7 +4581,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/secret/rotate/": {
+        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/secret/rotate": {
             "post": {
                 "description": "Rotates the endpoint's signing secret.  The previous secret will be valid for the next 24 hours.",
                 "operationId": "v1.endpoint.rotate-secret",
@@ -4703,7 +4703,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/send-example/": {
+        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/send-example": {
             "post": {
                 "description": "Send an example message for an event",
                 "operationId": "v1.endpoint.send-example",
@@ -4832,7 +4832,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/stats/": {
+        "/api/v1/app/{app_id}/endpoint/{endpoint_id}/stats": {
             "get": {
                 "description": "Get basic statistics for the endpoint.",
                 "operationId": "v1.endpoint.get-stats",
@@ -4962,7 +4962,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/msg/": {
+        "/api/v1/app/{app_id}/msg": {
             "get": {
                 "description": "List all of the application's messages.\n\nThe `before` parameter lets you filter all items created before a certain date and is ignored if an iterator is passed.\nThe `after` parameter lets you filter all items created after a certain date and is ignored if an iterator is passed.\n`before` and `after` cannot be used simultaneously.",
                 "operationId": "v1.message.list",
@@ -5249,7 +5249,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/msg/{msg_id}/": {
+        "/api/v1/app/{app_id}/msg/{msg_id}": {
             "get": {
                 "description": "Get a message by its ID or eventID.",
                 "operationId": "v1.message.get",
@@ -5370,7 +5370,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/msg/{msg_id}/attempt/": {
+        "/api/v1/app/{app_id}/msg/{msg_id}/attempt": {
             "get": {
                 "description": "Deprecated: Please use \"List Attempts by Endpoint\" and \"List Attempts by Msg\" instead.\n\n`msg_id`: Use a message id or a message `eventId`",
                 "operationId": "v1.message-attempt.list-by-msg-deprecated",
@@ -5586,7 +5586,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/msg/{msg_id}/attempt/{attempt_id}/": {
+        "/api/v1/app/{app_id}/msg/{msg_id}/attempt/{attempt_id}": {
             "get": {
                 "description": "`msg_id`: Use a message id or a message `eventId`",
                 "operationId": "v1.message-attempt.get",
@@ -5706,7 +5706,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/msg/{msg_id}/attempt/{attempt_id}/content/": {
+        "/api/v1/app/{app_id}/msg/{msg_id}/attempt/{attempt_id}/content": {
             "delete": {
                 "description": "Deletes the given attempt's response body. Useful when an endpoint accidentally returned sensitive content.",
                 "operationId": "v1.message-attempt.expunge-content",
@@ -5816,7 +5816,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/msg/{msg_id}/content/": {
+        "/api/v1/app/{app_id}/msg/{msg_id}/content": {
             "delete": {
                 "description": "Delete the given message's payload. Useful in cases when a message was accidentally sent with sensitive content.\n\nThe message can't be replayed or resent once its payload has been deleted or expired.",
                 "operationId": "v1.message.expunge-content",
@@ -5916,7 +5916,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/msg/{msg_id}/endpoint/": {
+        "/api/v1/app/{app_id}/msg/{msg_id}/endpoint": {
             "get": {
                 "description": "`msg_id`: Use a message id or a message `eventId`",
                 "operationId": "v1.message-attempt.list-attempted-destinations",
@@ -6050,7 +6050,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/msg/{msg_id}/endpoint/{endpoint_id}/attempt/": {
+        "/api/v1/app/{app_id}/msg/{msg_id}/endpoint/{endpoint_id}/attempt": {
             "get": {
                 "description": "DEPRECATED: please use list_attempts with endpoint_id as a query parameter instead.\n\nList the message attempts for a particular endpoint.\n\nReturning the endpoint.\n\nThe `before` parameter lets you filter all items created before a certain date and is ignored if an iterator is passed.",
                 "operationId": "v1.message-attempt.list-by-endpoint-deprecated",
@@ -6264,7 +6264,7 @@
                 ]
             }
         },
-        "/api/v1/app/{app_id}/msg/{msg_id}/endpoint/{endpoint_id}/resend/": {
+        "/api/v1/app/{app_id}/msg/{msg_id}/endpoint/{endpoint_id}/resend": {
             "post": {
                 "description": "Resend a message to the specified endpoint.",
                 "operationId": "v1.message-attempt.resend",
@@ -6389,7 +6389,7 @@
                 ]
             }
         },
-        "/api/v1/auth/app-portal-access/{app_id}/": {
+        "/api/v1/auth/app-portal-access/{app_id}": {
             "post": {
                 "description": "Use this function to get magic links (and authentication codes) for connecting your users to the Consumer Application Portal.",
                 "operationId": "v1.authentication.app-portal-access",
@@ -6505,7 +6505,7 @@
                 ]
             }
         },
-        "/api/v1/auth/dashboard-access/{app_id}/": {
+        "/api/v1/auth/dashboard-access/{app_id}": {
             "post": {
                 "description": "DEPRECATED: Please use `app-portal-access` instead.\n\nUse this function to get magic links (and authentication codes) for connecting your users to the Consumer Application Portal.",
                 "operationId": "v1.authentication.dashboard-access",
@@ -6611,7 +6611,7 @@
                 ]
             }
         },
-        "/api/v1/auth/logout/": {
+        "/api/v1/auth/logout": {
             "post": {
                 "description": "\nLogout an app token.\n\nTrying to log out other tokens will fail.\n",
                 "operationId": "logout_api_v1_auth_logout__post",
@@ -6637,7 +6637,7 @@
                 ]
             }
         },
-        "/api/v1/event-type/": {
+        "/api/v1/event-type": {
             "get": {
                 "description": "Return the list of event types.",
                 "operationId": "v1.event-type.list",
@@ -6881,7 +6881,7 @@
                 ]
             }
         },
-        "/api/v1/event-type/schema/generate-example/": {
+        "/api/v1/event-type/schema/generate-example": {
             "post": {
                 "description": "Generates a fake example from the given JSONSchema",
                 "parameters": [
@@ -6905,7 +6905,7 @@
                 ]
             }
         },
-        "/api/v1/event-type/{event_type_name}/": {
+        "/api/v1/event-type/{event_type_name}": {
             "delete": {
                 "description": "Archive an event type.\n\nEndpoints already configured to filter on an event type will continue to do so after archival.\nHowever, new messages can not be sent with it and endpoints can not filter on it.\nAn event type can be unarchived with the\n[create operation](#operation/create_event_type_api_v1_event_type__post).",
                 "operationId": "v1.event-type.delete",
@@ -7306,7 +7306,7 @@
                 ]
             }
         },
-        "/api/v1/health/": {
+        "/api/v1/health": {
             "get": {
                 "description": "Verify the API server is up and running.",
                 "operationId": "v1.health.get",
@@ -7451,7 +7451,7 @@
                 ]
             }
         },
-        "/api/v1/health/ping/": {
+        "/api/v1/health/ping": {
             "get": {
                 "responses": {
                     "204": {
diff --git a/server/svix-server/Cargo.toml b/server/svix-server/Cargo.toml
index fa8df3a0c..14ae0e6c2 100644
--- a/server/svix-server/Cargo.toml
+++ b/server/svix-server/Cargo.toml
@@ -26,7 +26,7 @@ hyper-openssl = "0.9.2"
 openssl = "0.10.60"
 tokio = { version = "1.24.2", features = ["full"] }
 tower = "0.4.11"
-tower-http = { version = "0.4.0", features = ["trace", "cors", "request-id"] }
+tower-http = { version = "0.4.4", features = ["trace", "cors", "normalize-path", "request-id"] }
 serde = { version = "1.0.184", features = ["derive"] }
 serde_json = { version = "1.0.74", features = ["arbitrary_precision", "raw_value"] }
 serde_path_to_error = "0.1.7"
diff --git a/server/svix-server/src/lib.rs b/server/svix-server/src/lib.rs
index d509ef944..ac9fadc16 100644
--- a/server/svix-server/src/lib.rs
+++ b/server/svix-server/src/lib.rs
@@ -19,8 +19,11 @@ use std::{
     sync::atomic::{AtomicBool, Ordering},
     time::Duration,
 };
-use tower::ServiceBuilder;
-use tower_http::cors::{AllowHeaders, Any, CorsLayer};
+use tower::layer::layer_fn;
+use tower_http::{
+    cors::{AllowHeaders, Any, CorsLayer},
+    normalize_path::NormalizePath,
+};
 use tracing_subscriber::layer::SubscriberExt as _;
 
 use crate::{
@@ -149,25 +152,25 @@ pub async fn run_with_prefix(
 
     let openapi = openapi::postprocess_spec(openapi);
     let docs_router = docs::router(openapi);
-    let app = app
-        .merge(docs_router)
-        .layer(
-            ServiceBuilder::new().layer_fn(move |service| IdempotencyService {
-                cache: svc_cache.clone(),
-                service,
-            }),
-        )
-        .layer(
-            CorsLayer::new()
-                .allow_origin(Any)
-                .allow_methods(Any)
-                .allow_headers(AllowHeaders::mirror_request())
-                .max_age(Duration::from_secs(600)),
-        );
+    let app = app.merge(docs_router).layer((
+        layer_fn(move |service| IdempotencyService {
+            cache: svc_cache.clone(),
+            service,
+        }),
+        CorsLayer::new()
+            .allow_origin(Any)
+            .allow_methods(Any)
+            .allow_headers(AllowHeaders::mirror_request())
+            .max_age(Duration::from_secs(600)),
+    ));
+    let svc = tower::make::Shared::new(
+        // It is important that this service wraps the router instead of being
+        // applied via `Router::layer`, as it would run after routing then.
+        NormalizePath::trim_trailing_slash(app),
+    );
 
     let with_api = cfg.api_enabled;
     let with_worker = cfg.worker_enabled;
-
     let listen_address = cfg.listen_address;
 
     let (server, worker_loop, expired_message_cleaner_loop) = tokio::join!(
@@ -177,13 +180,13 @@ pub async fn run_with_prefix(
                     tracing::debug!("API: Listening on {}", l.local_addr().unwrap());
                     axum::Server::from_tcp(l)
                         .expect("Error starting http server")
-                        .serve(app.into_make_service())
+                        .serve(svc)
                         .with_graceful_shutdown(graceful_shutdown_handler())
                         .await
                 } else {
                     tracing::debug!("API: Listening on {}", listen_address);
                     axum::Server::bind(&listen_address)
-                        .serve(app.into_make_service())
+                        .serve(svc)
                         .with_graceful_shutdown(graceful_shutdown_handler())
                         .await
                 }
diff --git a/server/svix-server/src/v1/endpoints/application.rs b/server/svix-server/src/v1/endpoints/application.rs
index 80dbcc1fe..738587568 100644
--- a/server/svix-server/src/v1/endpoints/application.rs
+++ b/server/svix-server/src/v1/endpoints/application.rs
@@ -389,13 +389,13 @@ pub fn router() -> ApiRouter<AppState> {
     let tag = openapi_tag("Application");
     ApiRouter::new()
         .api_route_with(
-            "/app/",
+            "/app",
             post_with(create_application, create_application_operation)
                 .get_with(list_applications, list_applications_operation),
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/",
+            "/app/:app_id",
             get_with(get_application, get_application_operation)
                 .put_with(update_application, update_application_operation)
                 .patch_with(patch_application, patch_application_operation)
diff --git a/server/svix-server/src/v1/endpoints/attempt.rs b/server/svix-server/src/v1/endpoints/attempt.rs
index b8afad5a3..bdcefb695 100644
--- a/server/svix-server/src/v1/endpoints/attempt.rs
+++ b/server/svix-server/src/v1/endpoints/attempt.rs
@@ -875,22 +875,22 @@ pub fn router() -> ApiRouter<AppState> {
     ApiRouter::new()
         // NOTE: [`list_messageattempts`] is deprecated
         .api_route_with(
-            "/app/:app_id/msg/:msg_id/attempt/",
+            "/app/:app_id/msg/:msg_id/attempt",
             get_with(list_messageattempts, list_messageattempts_operation),
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/msg/:msg_id/attempt/:attempt_id/",
+            "/app/:app_id/msg/:msg_id/attempt/:attempt_id",
             get_with(get_messageattempt, get_messageattempt_operation),
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/msg/:msg_id/attempt/:attempt_id/content/",
+            "/app/:app_id/msg/:msg_id/attempt/:attempt_id/content",
             delete_with(expunge_attempt_content, expunge_attempt_content_operation),
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/msg/:msg_id/endpoint/",
+            "/app/:app_id/msg/:msg_id/endpoint",
             get_with(
                 list_attempted_destinations,
                 list_attempted_destinations_operation,
@@ -898,13 +898,13 @@ pub fn router() -> ApiRouter<AppState> {
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/msg/:msg_id/endpoint/:endpoint_id/resend/",
+            "/app/:app_id/msg/:msg_id/endpoint/:endpoint_id/resend",
             post_with(resend_webhook, resend_webhook_operation),
             &tag,
         )
         // NOTE: [`list_attempts_for_endpoint`] is deprecated
         .api_route_with(
-            "/app/:app_id/msg/:msg_id/endpoint/:endpoint_id/attempt/",
+            "/app/:app_id/msg/:msg_id/endpoint/:endpoint_id/attempt",
             get_with(
                 list_attempts_for_endpoint,
                 list_attempts_for_endpoint_operation,
@@ -912,12 +912,12 @@ pub fn router() -> ApiRouter<AppState> {
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/endpoint/:endpoint_id/msg/",
+            "/app/:app_id/endpoint/:endpoint_id/msg",
             get_with(list_attempted_messages, list_attempted_messages_operation),
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/attempt/endpoint/:endpoint_id/",
+            "/app/:app_id/attempt/endpoint/:endpoint_id",
             get_with(
                 list_attempts_by_endpoint,
                 list_attempts_by_endpoint_operation,
@@ -925,7 +925,7 @@ pub fn router() -> ApiRouter<AppState> {
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/attempt/msg/:msg_id/",
+            "/app/:app_id/attempt/msg/:msg_id",
             get_with(list_attempts_by_msg, list_attempts_by_msg_operation),
             tag,
         )
diff --git a/server/svix-server/src/v1/endpoints/auth.rs b/server/svix-server/src/v1/endpoints/auth.rs
index 4e44cb8dc..0afe882d8 100644
--- a/server/svix-server/src/v1/endpoints/auth.rs
+++ b/server/svix-server/src/v1/endpoints/auth.rs
@@ -129,17 +129,17 @@ pub fn router() -> ApiRouter<AppState> {
     let tag = openapi_tag("Authentication");
     ApiRouter::new()
         .api_route_with(
-            "/auth/dashboard-access/:app_id/",
+            "/auth/dashboard-access/:app_id",
             post_with(dashboard_access, dashboard_access_operation),
             &tag,
         )
         .api_route_with(
-            "/auth/logout/",
+            "/auth/logout",
             post_with(api_not_implemented, logout_operation),
             &tag,
         )
         .api_route_with(
-            "/auth/app-portal-access/:app_id/",
+            "/auth/app-portal-access/:app_id",
             post_with(app_portal_access, app_portal_access_operation),
             tag,
         )
diff --git a/server/svix-server/src/v1/endpoints/endpoint/mod.rs b/server/svix-server/src/v1/endpoints/endpoint/mod.rs
index 567a306e0..5ddad0c46 100644
--- a/server/svix-server/src/v1/endpoints/endpoint/mod.rs
+++ b/server/svix-server/src/v1/endpoints/endpoint/mod.rs
@@ -818,13 +818,13 @@ pub fn router() -> ApiRouter<AppState> {
     let tag = openapi_tag("Endpoint");
     ApiRouter::new()
         .api_route_with(
-            "/app/:app_id/endpoint/",
+            "/app/:app_id/endpoint",
             post_with(crud::create_endpoint, crud::create_endpoint_operation)
                 .get_with(crud::list_endpoints, crud::list_endpoints_operation),
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/endpoint/:endpoint_id/",
+            "/app/:app_id/endpoint/:endpoint_id",
             get_with(crud::get_endpoint, crud::get_endpoint_operation)
                 .put_with(crud::update_endpoint, crud::update_endpoint_operation)
                 .patch_with(crud::patch_endpoint, crud::patch_endpoint_operation)
@@ -832,7 +832,7 @@ pub fn router() -> ApiRouter<AppState> {
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/endpoint/:endpoint_id/secret/",
+            "/app/:app_id/endpoint/:endpoint_id/secret",
             get_with(
                 secrets::get_endpoint_secret,
                 secrets::get_endpoint_secret_operation,
@@ -840,7 +840,7 @@ pub fn router() -> ApiRouter<AppState> {
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/endpoint/:endpoint_id/secret/rotate/",
+            "/app/:app_id/endpoint/:endpoint_id/secret/rotate",
             post_with(
                 secrets::rotate_endpoint_secret,
                 secrets::rotate_endpoint_secret_operation,
@@ -848,17 +848,17 @@ pub fn router() -> ApiRouter<AppState> {
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/endpoint/:endpoint_id/stats/",
+            "/app/:app_id/endpoint/:endpoint_id/stats",
             get_with(endpoint_stats, endpoint_stats_operation),
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/endpoint/:endpoint_id/send-example/",
+            "/app/:app_id/endpoint/:endpoint_id/send-example",
             post_with(send_example, send_example_operation),
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/endpoint/:endpoint_id/recover/",
+            "/app/:app_id/endpoint/:endpoint_id/recover",
             post_with(
                 recovery::recover_failed_webhooks,
                 recovery::recover_failed_webhooks_operation,
@@ -866,7 +866,7 @@ pub fn router() -> ApiRouter<AppState> {
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/endpoint/:endpoint_id/headers/",
+            "/app/:app_id/endpoint/:endpoint_id/headers",
             get_with(
                 headers::get_endpoint_headers,
                 headers::get_endpoint_headers_operation,
diff --git a/server/svix-server/src/v1/endpoints/event_type.rs b/server/svix-server/src/v1/endpoints/event_type.rs
index af585fe2b..d227c4837 100644
--- a/server/svix-server/src/v1/endpoints/event_type.rs
+++ b/server/svix-server/src/v1/endpoints/event_type.rs
@@ -414,13 +414,13 @@ pub fn router() -> ApiRouter<AppState> {
     let tag = openapi_tag("Event Type");
     ApiRouter::new()
         .api_route_with(
-            "/event-type/",
+            "/event-type",
             post_with(create_event_type, create_event_type_operation)
                 .get_with(list_event_types, list_event_types_operation),
             &tag,
         )
         .api_route_with(
-            "/event-type/:event_type_name/",
+            "/event-type/:event_type_name",
             get_with(get_event_type, get_event_type_operation)
                 .put_with(update_event_type, update_event_type_operation)
                 .patch_with(patch_event_type, patch_event_type_operation)
@@ -428,7 +428,7 @@ pub fn router() -> ApiRouter<AppState> {
             &tag,
         )
         .api_route_with(
-            "/event-type/schema/generate-example/",
+            "/event-type/schema/generate-example",
             post_with(
                 api_not_implemented,
                 openapi_desc(GENERATE_SCHEMA_EXAMPLE_DESCRIPTION),
diff --git a/server/svix-server/src/v1/endpoints/health.rs b/server/svix-server/src/v1/endpoints/health.rs
index 6584e4e41..da8e59d57 100644
--- a/server/svix-server/src/v1/endpoints/health.rs
+++ b/server/svix-server/src/v1/endpoints/health.rs
@@ -133,9 +133,9 @@ pub fn router() -> ApiRouter<AppState> {
     let tag = openapi_tag("Health");
 
     ApiRouter::new()
-        .api_route("/health/ping/", get(ping).head(ping))
+        .api_route("/health/ping", get(ping).head(ping))
         .api_route_with(
-            "/health/",
+            "/health",
             get_with(health, |op| op.response::<204, ()>().with(health_operation))
                 .head_with(health, health_operation),
             tag,
diff --git a/server/svix-server/src/v1/endpoints/message.rs b/server/svix-server/src/v1/endpoints/message.rs
index 314c0bc18..37978f2c8 100644
--- a/server/svix-server/src/v1/endpoints/message.rs
+++ b/server/svix-server/src/v1/endpoints/message.rs
@@ -454,18 +454,18 @@ pub fn router() -> ApiRouter<AppState> {
     let tag = openapi_tag("Message");
     ApiRouter::new()
         .api_route_with(
-            "/app/:app_id/msg/",
+            "/app/:app_id/msg",
             post_with(create_message, create_message_operation)
                 .get_with(list_messages, list_messages_operation),
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/msg/:msg_id/",
+            "/app/:app_id/msg/:msg_id",
             get_with(get_message, get_message_operation),
             &tag,
         )
         .api_route_with(
-            "/app/:app_id/msg/:msg_id/content/",
+            "/app/:app_id/msg/:msg_id/content",
             delete_with(expunge_message_content, expunge_message_content_operation),
             tag,
         )


Test patch:
diff --git a/server/svix-server/tests/it/e2e_health.rs b/server/svix-server/tests/it/e2e_health.rs
new file mode 100644
index 000000000..7f7c59bca
--- /dev/null
+++ b/server/svix-server/tests/it/e2e_health.rs
@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: © 2024 Svix Authors
+// SPDX-License-Identifier: MIT
+
+use reqwest::StatusCode;
+
+use crate::utils::{start_svix_server, IgnoredResponse};
+
+#[tokio::test]
+async fn ping_with_trailing_slash() {
+    let (client, _jh) = start_svix_server().await;
+
+    let _: IgnoredResponse = client
+        .get("api/v1/health/ping/", StatusCode::NO_CONTENT)
+        .await
+        .unwrap();
+}
+
+#[tokio::test]
+async fn ping_without_trailing_slash() {
+    let (client, _jh) = start_svix_server().await;
+
+    let _: IgnoredResponse = client
+        .get("api/v1/health/ping", StatusCode::NO_CONTENT)
+        .await
+        .unwrap();
+}
diff --git a/server/svix-server/tests/it/main.rs b/server/svix-server/tests/it/main.rs
index f8ce1ce31..0b4b4f83c 100644
--- a/server/svix-server/tests/it/main.rs
+++ b/server/svix-server/tests/it/main.rs
@@ -4,6 +4,7 @@ mod e2e_attempt;
 mod e2e_auth;
 mod e2e_endpoint;
 mod e2e_event_type;
+mod e2e_health;
 mod e2e_message;
 mod e2e_operational_webhooks;
 mod integ_webhook_http_client;