Skip to content
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

feat(extensions): Add extensions support #2634

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a9ebf89
Tailcall extensions initial draft
karatakis Aug 9, 2024
1fe8984
Merge branch 'main' into feat/tailcall-extensions
karatakis Aug 9, 2024
64e3943
Fix example to use jsonplaceholder, and lint
karatakis Aug 9, 2024
644a020
Update examples/extension-i18n/main.graphql
karatakis Aug 9, 2024
5b42b3a
Commit new package
karatakis Aug 9, 2024
7a6bf36
Merge branch 'main' into feat/tailcall-extensions
karatakis Aug 9, 2024
a8f9605
Add extension example
karatakis Aug 10, 2024
5a7cf8a
Merge branch 'main' into feat/tailcall-extensions
karatakis Aug 10, 2024
cb97c81
Add features crate
karatakis Aug 10, 2024
58c30f2
Add readme for example
karatakis Aug 10, 2024
85825a5
Attempt to make tests
karatakis Aug 12, 2024
f8ea852
Fix lint
karatakis Aug 12, 2024
51c047a
Fix test
karatakis Aug 12, 2024
ac70681
Merge branch 'main' into feat/tailcall-extensions
karatakis Aug 12, 2024
9356edc
Fix discussion issues
karatakis Aug 12, 2024
cb94266
Fix readme
karatakis Aug 12, 2024
fc174c2
Remove dead code
karatakis Aug 12, 2024
e627d0c
Fix file to compile
karatakis Aug 12, 2024
0f376cc
Separate extensions into their own files
karatakis Aug 16, 2024
d18a30e
Read translations from file
karatakis Aug 16, 2024
04cd151
Merge branch 'main' into feat/tailcall-extensions
karatakis Aug 16, 2024
f1ea784
Fix lint
karatakis Aug 16, 2024
4a6bfee
Fix code
karatakis Aug 16, 2024
f08b31b
Update tailcall spec
karatakis Aug 16, 2024
217dc1d
Merge branch 'main' into feat/tailcall-extensions
karatakis Aug 19, 2024
16e11bf
Merge branch 'main' into feat/tailcall-extensions
karatakis Aug 19, 2024
656255d
Merge branch 'main' into feat/tailcall-extensions
karatakis Aug 23, 2024
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
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ members = [
"tailcall-hasher",
"tailcall-http-cache",
"tailcall-version",
"examples/extension-i18n",
]

# Boost execution_spec snapshot diffing performance
Expand Down
19 changes: 19 additions & 0 deletions examples/extension-i18n/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "extension-i18n"
version = "0.1.0"
edition = "2021"

[dependencies]
tailcall = { path = "../.." }
tracing = { workspace = true }
dotenvy = "0.15.7"
anyhow = { workspace = true }
tokio = { workspace = true }
async-graphql = { workspace = true }
async-graphql-value = "7.0.3"
async-trait = "0.1.80"

[dev-dependencies]
reqwest = { workspace = true }
serde_json = { workspace = true }
hyper = { version = "0.14.28", default-features = false }
21 changes: 21 additions & 0 deletions examples/extension-i18n/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Tailcall Extension Example

In this example we showcase the extension capabilities Tailcall supports. We allow the developers to extend Tailcall functionality in the form of custom extensions that enable to hook into the Tailcall runtime. You can utilize extensions using the `@extension` directive. In this project we have examples for two extension scenarios. One for modifying the IR each time, and one to modify a value before returned to the response. See `ExtensionLoader` trait for more information.

## Running

To run the example run the `cargo run -p extension-i18n` command from the root folder of `tailcall` project.

## Example query

```gql
{
user(id: 1) {
id
name
company {
catchPhrase
}
}
}
```
21 changes: 21 additions & 0 deletions examples/extension-i18n/main.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
schema @server(port: 8800) @upstream(baseURL: "http://jsonplaceholder.typicode.com", batch: {maxSize: 10, delay: 10}) {
query: Query
}

type Query {
users: [User] @http(path: "/users")
user(id: ID!): User @http(path: "/users/{{.args.id}}") @extension(name: "modify_ir", params: ["{{.args.id}}"])
}

type User {
id: ID
name: String @extension(name: "translate", params: ["userName", "{{.value.name}}"])
username: String
company: Company
}

type Company {
name: String
catchPhrase: String @extension(name: "translate", params: ["catchPhrase", "{{.value.catchPhrase}}"])
bs: String
}
276 changes: 276 additions & 0 deletions examples/extension-i18n/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
use std::sync::{Arc, Mutex};

use async_graphql_value::ConstValue;
use dotenvy::dotenv;
use tailcall::cli::runtime;
use tailcall::cli::server::Server;
use tailcall::core::blueprint::{Blueprint, ExtensionTrait, PrepareContext, ProcessContext};
use tailcall::core::config::reader::ConfigReader;
use tailcall::core::config::KeyValue;
use tailcall::core::helpers::headers::to_mustache_headers;
use tailcall::core::valid::Validator;

#[derive(Clone, Debug)]
pub struct TranslateExtension {
pub load_counter: Arc<Mutex<i32>>,
pub prepare_counter: Arc<Mutex<i32>>,
pub process_counter: Arc<Mutex<i32>>,
}

impl Default for TranslateExtension {
fn default() -> Self {
Self {
load_counter: Arc::new(Mutex::new(0)),
prepare_counter: Arc::new(Mutex::new(0)),
process_counter: Arc::new(Mutex::new(0)),
}
}
}

#[async_trait::async_trait]
impl ExtensionTrait<ConstValue> for TranslateExtension {
fn load(&self) {
*(self.load_counter.lock().unwrap()) += 1;
}

async fn prepare(
&self,
context: PrepareContext<ConstValue>,
) -> Box<tailcall::core::ir::model::IR> {
*(self.prepare_counter.lock().unwrap()) += 1;
context.ir
}

async fn process(
&self,
context: ProcessContext<ConstValue>,
) -> Result<ConstValue, tailcall::core::ir::Error> {
*(self.process_counter.lock().unwrap()) += 1;
if let ConstValue::String(value) = context.value {
let new_value = match value.as_str() {
"Multi-layered client-server neural-net" => {
"Red neuronal cliente-servidor multicapa".to_string()
}
"Leanne Graham" => "Leona Grahm".to_string(),
karatakis marked this conversation as resolved.
Show resolved Hide resolved
_ => value.to_string(),
};
Ok(ConstValue::String(new_value))
} else {
Ok(context.value)
}
}
}

#[derive(Clone, Debug)]
pub struct ModifyIrExtension {
pub load_counter: Arc<Mutex<i32>>,
pub prepare_counter: Arc<Mutex<i32>>,
pub process_counter: Arc<Mutex<i32>>,
}

impl Default for ModifyIrExtension {
fn default() -> Self {
Self {
load_counter: Arc::new(Mutex::new(0)),
prepare_counter: Arc::new(Mutex::new(0)),
process_counter: Arc::new(Mutex::new(0)),
}
}
}

#[async_trait::async_trait]
impl ExtensionTrait<ConstValue> for ModifyIrExtension {
fn load(&self) {
*(self.load_counter.lock().unwrap()) += 1;
}

async fn prepare(
&self,
context: PrepareContext<ConstValue>,
) -> Box<tailcall::core::ir::model::IR> {
*(self.prepare_counter.lock().unwrap()) += 1;
if let tailcall::core::ir::model::IR::IO(tailcall::core::ir::model::IO::Http {
req_template,
group_by,
dl_id,
http_filter,
}) = *context.ir
{
let mut req_template = req_template;
let headers = to_mustache_headers(&[KeyValue {
key: "Authorization".to_string(),
value: "Bearer 1234".to_string(),
}]);

match headers.to_result() {
Ok(mut headers) => {
req_template.headers.append(&mut headers);
}
Err(_) => panic!("Headers are not structured properly"),
};

let ir = tailcall::core::ir::model::IR::IO(tailcall::core::ir::model::IO::Http {
group_by,
dl_id,
http_filter,
req_template,
});
Box::new(ir)
} else {
context.ir
}
}

async fn process(
&self,
context: ProcessContext<ConstValue>,
) -> Result<ConstValue, tailcall::core::ir::Error> {
*(self.process_counter.lock().unwrap()) += 1;
Ok(context.value)
}
}
karatakis marked this conversation as resolved.
Show resolved Hide resolved

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let translate_ext = Arc::new(TranslateExtension::default());
let modify_ir_ext = Arc::new(ModifyIrExtension::default());
if let Ok(path) = dotenv() {
tracing::info!("Env file: {:?} loaded", path);
}
let runtime = runtime::init(&Blueprint::default());
let config_reader = ConfigReader::init(runtime.clone());
let file_paths = ["./examples/extension-i18n/main.graphql"];
let config_module = config_reader.read_all(file_paths.as_ref()).await?;
let mut extensions = config_module.extensions().clone();
extensions
.plugin_extensions
.insert("translate".to_string(), translate_ext);
extensions
.plugin_extensions
.insert("modify_ir".to_string(), modify_ir_ext);
let config_module = config_module.merge_extensions(extensions);
let server = Server::new(config_module);
server.fork_start().await?;
Ok(())
}

#[cfg(test)]
mod tests {
use hyper::{Body, Request};
use serde_json::json;
use tailcall::core::app_context::AppContext;
use tailcall::core::async_graphql_hyper::GraphQLRequest;
use tailcall::core::http::{handle_request, Response};
use tailcall::core::rest::EndpointSet;
use tailcall::core::HttpIO;

use super::*;

struct MockHttp;

#[async_trait::async_trait]
impl HttpIO for MockHttp {
async fn execute(
&self,
_request: reqwest::Request,
) -> anyhow::Result<Response<hyper::body::Bytes>> {
let data = json!({
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "[email protected]",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markets"
}
})
.to_string();
Ok(Response {
body: data.into(),
status: reqwest::StatusCode::OK,
headers: reqwest::header::HeaderMap::new(),
})
}
}

#[tokio::test]
async fn test_tailcall_extensions() {
let translate_ext = Arc::new(TranslateExtension::default());
let modify_ir_ext = Arc::new(ModifyIrExtension::default());
if let Ok(path) = dotenv() {
tracing::info!("Env file: {:?} loaded", path);
}
let mut runtime = runtime::init(&Blueprint::default());
runtime.http = Arc::new(MockHttp {});
runtime.http2_only = Arc::new(MockHttp {});
let config_reader = ConfigReader::init(runtime.clone());
let file_paths = ["./main.graphql"];
let config_module = config_reader.read_all(file_paths.as_ref()).await.unwrap();
let mut extensions = config_module.extensions().clone();
extensions
.plugin_extensions
.insert("translate".to_string(), translate_ext.clone());
extensions
.plugin_extensions
.insert("modify_ir".to_string(), modify_ir_ext.clone());
let config_module = config_module.merge_extensions(extensions);
let blueprint = Blueprint::try_from(&config_module).unwrap();
let app_context = AppContext::new(blueprint, runtime, EndpointSet::default());

let query = json!({
"query": "{ user(id: 1) { id name company { catchPhrase } } }"
});
let body = Body::from(query.to_string());
let req = Request::builder()
.method("POST")
.uri("http://127.0.0.1:8800/graphql")
.body(body)
.unwrap();

let response = handle_request::<GraphQLRequest>(req, Arc::new(app_context))
.await
.unwrap();
let response = tailcall::core::http::Response::from_hyper(response)
.await
.unwrap();

let expected_response = json!({
"data": {
"user": {
"id": 1,
"name": "Leona Grahm",
"company": {
"catchPhrase": "Red neuronal cliente-servidor multicapa"
}
}
}
});

assert_eq!(
response.body,
hyper::body::Bytes::from(expected_response.to_string()),
"Unexpected response from server"
);

assert_eq!(translate_ext.load_counter.lock().unwrap().to_owned(), 2);
assert_eq!(translate_ext.process_counter.lock().unwrap().to_owned(), 2);
assert_eq!(translate_ext.prepare_counter.lock().unwrap().to_owned(), 2);

assert_eq!(modify_ir_ext.load_counter.lock().unwrap().to_owned(), 1);
assert_eq!(modify_ir_ext.process_counter.lock().unwrap().to_owned(), 1);
assert_eq!(modify_ir_ext.prepare_counter.lock().unwrap().to_owned(), 1);
}
}
Loading
Loading