diff --git a/generated/.tailcallrc.graphql b/generated/.tailcallrc.graphql index d41a58121e..bcb21779c5 100644 --- a/generated/.tailcallrc.graphql +++ b/generated/.tailcallrc.graphql @@ -296,6 +296,12 @@ directive @server( """ responseValidation: Boolean """ + `routes` allows customization of server endpoint paths. It provides options to change + the default paths for status and GraphQL endpoints. Default values are: - status: + "/status" - graphQL: "/graphql" If not specified, these default values will be used. + """ + routes: Routes + """ A link to an external JS file that listens on every HTTP request response event. """ script: ScriptOptions @@ -634,6 +640,11 @@ input Headers { setCookies: Boolean } +input Routes { + graphQL: String! + status: String! +} + input ScriptOptions { timeout: Int } diff --git a/generated/.tailcallrc.schema.json b/generated/.tailcallrc.schema.json index 654c30eedf..ef06397799 100644 --- a/generated/.tailcallrc.schema.json +++ b/generated/.tailcallrc.schema.json @@ -952,6 +952,19 @@ } } }, + "Routes": { + "type": "object", + "properties": { + "graphQL": { + "default": "/graphql", + "type": "string" + }, + "status": { + "default": "/status", + "type": "string" + } + } + }, "ScriptOptions": { "type": "object", "properties": { @@ -1059,6 +1072,17 @@ "null" ] }, + "routes": { + "description": "`routes` allows customization of server endpoint paths. It provides options to change the default paths for status and GraphQL endpoints. Default values are: - status: \"/status\" - graphQL: \"/graphql\" If not specified, these default values will be used.", + "anyOf": [ + { + "$ref": "#/definitions/Routes" + }, + { + "type": "null" + } + ] + }, "script": { "description": "A link to an external JS file that listens on every HTTP request response event.", "anyOf": [ diff --git a/src/cli/server/mod.rs b/src/cli/server/mod.rs index 0d556dacf8..3cd410e27b 100644 --- a/src/cli/server/mod.rs +++ b/src/cli/server/mod.rs @@ -8,8 +8,6 @@ pub use http_server::Server; use self::server_config::ServerConfig; -const GRAPHQL_SLUG: &str = "/graphql"; - fn log_launch(sc: &ServerConfig) { let addr = sc.addr().to_string(); tracing::info!( @@ -18,7 +16,9 @@ fn log_launch(sc: &ServerConfig) { sc.http_version() ); - let graphiql_url = sc.graphiql_url() + GRAPHQL_SLUG; + let gql_slug = sc.app_ctx.blueprint.server.routes.graphql(); + + let graphiql_url = sc.graphiql_url() + gql_slug; let url = playground::build_url(&graphiql_url); tracing::info!("🌍 Playground: {}", url); } diff --git a/src/core/blueprint/server.rs b/src/core/blueprint/server.rs index e8077a1514..df56169784 100644 --- a/src/core/blueprint/server.rs +++ b/src/core/blueprint/server.rs @@ -11,7 +11,7 @@ use rustls_pki_types::{CertificateDer, PrivateKeyDer}; use super::Auth; use crate::core::blueprint::Cors; -use crate::core::config::{self, ConfigModule, HttpVersion}; +use crate::core::config::{self, ConfigModule, HttpVersion, Routes}; use crate::core::valid::{Valid, ValidationError, Validator}; #[derive(Clone, Debug, Setters)] @@ -38,6 +38,7 @@ pub struct Server { pub experimental_headers: HashSet, pub auth: Option, pub dedupe: bool, + pub routes: Routes, } /// Mimic of mini_v8::Script that's wasm compatible @@ -154,6 +155,7 @@ impl TryFrom for Server { cors, auth, dedupe: config_server.get_dedupe(), + routes: config_server.get_routes(), } }, ) diff --git a/src/core/config/server.rs b/src/core/config/server.rs index 960e8ce40e..12087e9cb7 100644 --- a/src/core/config/server.rs +++ b/src/core/config/server.rs @@ -1,5 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; +use derive_getters::Getters; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tailcall_macros::DirectiveDefinition; @@ -120,6 +122,47 @@ pub struct Server { /// `workers` sets the number of worker threads. @default the number of /// system cores. pub workers: Option, + + #[serde(default, skip_serializing_if = "is_default")] + /// `routes` allows customization of server endpoint paths. + /// It provides options to change the default paths for status and GraphQL + /// endpoints. Default values are: + /// - status: "/status" + /// - graphQL: "/graphql" If not specified, these default values will be + /// used. + pub routes: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, MergeRight, JsonSchema, Getters)] +pub struct Routes { + #[serde(default = "default_status")] + status: String, + #[serde(rename = "graphQL", default = "default_graphql")] + graphql: String, +} + +fn default_status() -> String { + "/status".into() +} + +fn default_graphql() -> String { + "/graphql".into() +} + +impl Default for Routes { + fn default() -> Self { + Self { status: "/status".into(), graphql: "/graphql".into() } + } +} + +impl Routes { + pub fn with_status>(self, status: T) -> Self { + Self { graphql: self.graphql, status: status.into() } + } + + pub fn with_graphql>(self, graphql: T) -> Self { + Self { status: self.status, graphql: graphql.into() } + } } fn merge_right_vars(mut left: Vec, right: Vec) -> Vec { @@ -231,6 +274,10 @@ impl Server { pub fn enable_jit(&self) -> bool { self.enable_jit.unwrap_or(true) } + + pub fn get_routes(&self) -> Routes { + self.routes.clone().unwrap_or_default() + } } #[cfg(test)] diff --git a/src/core/http/request_handler.rs b/src/core/http/request_handler.rs index 8357219a2d..3208a16cb7 100644 --- a/src/core/http/request_handler.rs +++ b/src/core/http/request_handler.rs @@ -302,11 +302,14 @@ async fn handle_request_inner( return handle_rest_apis(req, app_ctx, req_counter).await; } + let health_check_endpoint = app_ctx.blueprint.server.routes.status(); + let graphql_endpoint = app_ctx.blueprint.server.routes.graphql(); + match *req.method() { // NOTE: // The first check for the route should be for `/graphql` // This is always going to be the most used route. - hyper::Method::POST if req.uri().path() == "/graphql" => { + hyper::Method::POST if req.uri().path() == graphql_endpoint => { graphql_request::(req, &app_ctx, req_counter).await } hyper::Method::POST @@ -321,7 +324,7 @@ async fn handle_request_inner( graphql_request::(req, &Arc::new(app_ctx), req_counter).await } - hyper::Method::GET if req.uri().path() == "/status" => { + hyper::Method::GET if req.uri().path() == health_check_endpoint => { let status_response = Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "application/json") @@ -385,7 +388,7 @@ mod test { use super::*; use crate::core::async_graphql_hyper::GraphQLRequest; use crate::core::blueprint::Blueprint; - use crate::core::config::{Config, ConfigModule}; + use crate::core::config::{Config, ConfigModule, Routes}; use crate::core::rest::EndpointSet; use crate::core::runtime::test::init; use crate::core::valid::Validator; @@ -394,7 +397,8 @@ mod test { async fn test_health_endpoint() -> anyhow::Result<()> { let sdl = tokio::fs::read_to_string(tailcall_fixtures::configs::JSONPLACEHOLDER).await?; let config = Config::from_sdl(&sdl).to_result()?; - let blueprint = Blueprint::try_from(&ConfigModule::from(config))?; + let mut blueprint = Blueprint::try_from(&ConfigModule::from(config))?; + blueprint.server.routes = Routes::default().with_status("/health"); let app_ctx = Arc::new(AppContext::new( blueprint, init(None), @@ -403,7 +407,7 @@ mod test { let req = Request::builder() .method(Method::GET) - .uri("http://localhost:8000/status".to_string()) + .uri("http://localhost:8000/health".to_string()) .body(Body::empty())?; let resp = handle_request::(req, app_ctx).await?; @@ -415,6 +419,36 @@ mod test { Ok(()) } + #[tokio::test] + async fn test_graphql_endpoint() -> anyhow::Result<()> { + let sdl = tokio::fs::read_to_string(tailcall_fixtures::configs::JSONPLACEHOLDER).await?; + let config = Config::from_sdl(&sdl).to_result()?; + let mut blueprint = Blueprint::try_from(&ConfigModule::from(config))?; + blueprint.server.routes = Routes::default().with_graphql("/gql"); + let app_ctx = Arc::new(AppContext::new( + blueprint, + init(None), + EndpointSet::default(), + )); + + let query = r#"{"query": "{ __schema { queryType { name } } }"}"#; + let req = Request::builder() + .method(Method::POST) + .uri("http://localhost:8000/gql".to_string()) + .header("Content-Type", "application/json") + .body(Body::from(query))?; + + let resp = handle_request::(req, app_ctx).await?; + + assert_eq!(resp.status(), StatusCode::OK); + let body = hyper::body::to_bytes(resp.into_body()).await?; + let body_str = String::from_utf8(body.to_vec())?; + assert!(body_str.contains("queryType")); + assert!(body_str.contains("name")); + + Ok(()) + } + #[test] fn test_create_allowed_headers() { use std::collections::BTreeSet; diff --git a/tests/core/snapshots/routes-param-on-server-directive.md_0.snap b/tests/core/snapshots/routes-param-on-server-directive.md_0.snap new file mode 100644 index 0000000000..f683c854d5 --- /dev/null +++ b/tests/core/snapshots/routes-param-on-server-directive.md_0.snap @@ -0,0 +1,19 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "users": [ + { + "name": "Leanne Graham" + } + ] + } + } +} diff --git a/tests/core/snapshots/routes-param-on-server-directive.md_1.snap b/tests/core/snapshots/routes-param-on-server-directive.md_1.snap new file mode 100644 index 0000000000..b5a72701af --- /dev/null +++ b/tests/core/snapshots/routes-param-on-server-directive.md_1.snap @@ -0,0 +1,13 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "message": "ready" + } +} diff --git a/tests/core/snapshots/routes-param-on-server-directive.md_client.snap b/tests/core/snapshots/routes-param-on-server-directive.md_client.snap new file mode 100644 index 0000000000..e05605ebef --- /dev/null +++ b/tests/core/snapshots/routes-param-on-server-directive.md_client.snap @@ -0,0 +1,51 @@ +--- +source: tests/core/spec.rs +expression: formatted +--- +scalar Bytes + +scalar Date + +scalar DateTime + +scalar Email + +scalar Empty + +scalar Int128 + +scalar Int16 + +scalar Int32 + +scalar Int64 + +scalar Int8 + +scalar JSON + +scalar PhoneNumber + +type Query { + users: [User] +} + +scalar UInt128 + +scalar UInt16 + +scalar UInt32 + +scalar UInt64 + +scalar UInt8 + +scalar Url + +type User { + name: String +} + +schema { + query: Query +} diff --git a/tests/core/snapshots/routes-param-on-server-directive.md_merged.snap b/tests/core/snapshots/routes-param-on-server-directive.md_merged.snap new file mode 100644 index 0000000000..bbb4a061d2 --- /dev/null +++ b/tests/core/snapshots/routes-param-on-server-directive.md_merged.snap @@ -0,0 +1,15 @@ +--- +source: tests/core/spec.rs +expression: formatter +--- +schema @server(port: 8000, routes: {status: "/health", graphQL: "/tailcall-gql"}) @upstream { + query: Query +} + +type Query { + users: [User] @http(baseURL: "http://jsonplaceholder.typicode.com", path: "/users") +} + +type User { + name: String +} diff --git a/tests/execution/routes-param-on-server-directive.md b/tests/execution/routes-param-on-server-directive.md new file mode 100644 index 0000000000..049a3dcffc --- /dev/null +++ b/tests/execution/routes-param-on-server-directive.md @@ -0,0 +1,38 @@ +# Sending field index list + +```graphql @config +schema @server(port: 8000, routes: {graphQL: "/tailcall-gql", status: "/health"}) { + query: Query +} + +type User { + name: String +} + +type Query { + users: [User] @http(path: "/users", baseURL: "http://jsonplaceholder.typicode.com") +} +``` + +```yml @mock +- request: + method: GET + url: http://jsonplaceholder.typicode.com/users + response: + status: 200 + body: + - id: 1 + name: Leanne Graham +``` + +```yml @test +- method: POST + url: http://localhost:8080/tailcall-gql + body: + query: query { users { name } } + +- method: GET + url: http://localhost:8080/health + body: + query: query { users { name } } +```