diff --git a/Cargo.toml b/Cargo.toml index 392eba844b60ea17c9d1259b183b25094128377c..b443467d5617701a418b3cdace8388a79187a532 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "drone" -version = "0.2.6" +version = "0.3.0" edition = "2021" authors = ["Deathwing <hi@deathwing.me>"] description = "A caching reverse-proxy application for the Hive blockchain." @@ -12,7 +12,6 @@ actix-web = "4.3.1" reqwest = { version = "0.11.14", features = ["blocking", "json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.94" -serde_with = "2.3.1" serde_yaml = "0.8" humantime = "2.1.0" config = "0.13.3" @@ -20,6 +19,5 @@ actix-cors = "0.6.4" async-recursion = "1.0.5" moka = { version = "0.12.5", features = ["future"] } sequence_trie = "0.3.6" -futures-util = "0.3.30" chrono = "0.4.34" tokio = { version = "1.36.0", features = ["sync"] } diff --git a/README.md b/README.md index 95e6d0a51ad163a9fbc35e0b14b2488daf07f507..18082ac06aa45833b76eddc94549268c74b6b487 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Drone -Drone is an API caching layer application for the Hive blockchain. It is built using Rust with Actix Web, and its primary purpose is to cache and serve API requests for a specific set of methods. While Drone is not meant to be a Jussi replacement, it aims to improve API node performance. +Drone is an API caching layer application for the Hive blockchain. It is built using Rust with Actix Web, and its primary purpose is to cache and serve API requests for a specific set of methods. +Drone is totally meant to be a Jussi replacement, it aims to improve API node performance. ## Features @@ -13,20 +14,12 @@ Drone is an API caching layer application for the Hive blockchain. It is built u ## Cached API Methods -Due to the speed of the blockchain and ease of access, only certain API methods are available for caching by default. This is editable by the node operator if they see the need for it. - -* block_api.get_block -* condenser_api.get_block -* account_history_api.get_transaction -* condenser_api.get_transaction -* condenser_api.get_ops_in_block -* condenser_api.get_block_range -* block_api.get_block_range +The list of which methods are cached and their cache TTL is configured in the config.yaml file. The keys used to specify the method names in the config file Jussi's rules for parsing +method names, so you should be able to port your existing Jussi config.json easily. ## Endpoints - The application has the following two primary endpoints: `GET /`: Health check endpoint that returns the application status, version, and operator message in JSON format. @@ -35,25 +28,23 @@ The application has the following two primary endpoints: ## Configuration -Drone comes with pre-determined settings, however, you will have to edit ENDPOINT settings in config.json before starting the application (or building the Docker image) +Drone comes with pre-determined settings, however, you will have to edit ENDPOINT settings in `drone` section of `config.yaml` +before starting the application (or building the Docker image) ``` -PORT: The port on which the application will listen for incoming connections (default: 8999). -HOSTNAME: The hostname/IP address the application will bind to (default: "0.0.0.0"). -CACHE_TTL: Time-to-live for cache entries (default: 300 seconds). -CACHE_COUNT: Maximum number of entries the cache can hold (default: 250). -OPERATOR_MESSAGE: Customizable message from the operator (default: "Drone by Deathwing"). -HAF_ENDPOINT: HAF Endpoint that Drone can connect to relay HAF related API calls. -HAFAH_ENDPOINT: HAFAH Endpoint that Drone can connect to relay HAFAH related API calls. -HIVEMIND_ENDPOINT: Hivemind Endpoint that Drone can connect to relay Hivemind related API calls. -MIDDLEWARE_CONNECTION_THREADS: Specifies the number of HTTP connections to Hive endpoints kept alive (default: 8). +port: The port on which the application will listen for incoming connections (default: 8999). +hostname: The hostname/IP address the application will bind to (default: "0.0.0.0"). +cache_max_capacity: The approximate max size of the cache, in bytes. Memory usage may slightly exceed this + limit, due to lazy eviction, but not by much. +operator_message: Customizable message from the operator (default: "Drone by Deathwing"). +middleware_connection_threads: Specifies the number of HTTP connections to Hive endpoints kept alive (default: 8). ``` ## Usage ### Native -To start the application after altering necessary configuration parameters such as `HAF_ENDPOINT` execute the following command: +To start the application after altering necessary configuration parameters execute the following command: `cargo run --release` @@ -63,4 +54,4 @@ If you are advanced and have knowledge about Rust, you can also build the binary You can use docker-compose to build and run Drone. -`docker-compose up --build -d` \ No newline at end of file +`docker-compose up --build -d` diff --git a/config.example.json b/config.example.json deleted file mode 100644 index eaa2206f2f39a0dff8669c0eb39323cc5753d00c..0000000000000000000000000000000000000000 --- a/config.example.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "PORT": "8999", - "HOSTNAME": "0.0.0.0", - "CACHE_TTL": 300, - "CACHE_COUNT": 250, - "OPERATOR_MESSAGE": "Drone by Deathwing", - "HAF_ENDPOINT": "http://HAF:HAFPORT", - "HAFAH_ENDPOINT": "http://HAFAH:HAFAHPORT", - "HIVEMIND_ENDPOINT": "http://HIVEMIND:HIVEMINDPORT", - "MIDDLEWARE_CONNECTION_THREADS": 8 -} \ No newline at end of file diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b38c3b4abf178241c1c39674b0a0b2ed0fc751f4 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,181 @@ +--- +drone: + port: 9000 + hostname: 0.0.0.0 + cache_max_capacity: 4294967296 # 4GB + operator_message: "Drone by Deathwing" + middleware_connection_threads: 8 +# The remainder of this file is based on the information in existing jussi config files, +# just in a more concise yaml format. + +# a list of backends Drone will send calls to, these are referenced +# in the 'urls' section +backends: + hived: http://haproxy:7008 + hivemind: http://haproxy:7002 + hafah: http://haproxy:7003 + hived-sync: http://haproxy:7006 + hafsql: http://haproxy:7007 + +# translate calls in these namespaces to appbase calls. calls to methods +# in other namespaces will not be translated. +# This is done after jussi has rewritten the method name. +translate_to_appbase: + - hived + +# tells jussi where to forward calls based on method name. +# In this section, and in the ttls and timeouts section, the most specific match wins, +# the order they appear in this file doesn't matter +urls: + bridge: hivemind + hafsql-api: hafsql + hive: hivemind + hived: hived + hived.network_broadcast_api.broadcast_transaction_synchronous: hived-sync + appbase: hived + appbase.condenser_api.get_account_reputations: hivemind + appbase.follow_api.get_account_reputations: hivemind + appbase.condenser_api.get_accounts: hived + appbase.condenser_api.broadcast_block: hived + appbase.condenser_api.broadcast_transaction: hived + appbase.condenser_api.broadcast_transaction_synchronous: hived-sync + appbase.network_broadcast_api.broadcast_transaction_synchronous: hived-sync + appbase.network_broadcast_api: hived + appbase.condenser_api.get_block: hived + appbase.block_api.get_block: hafah + appbase.block_api.get_block_header: hafah + appbase.block_api.get_block_range: hafah + appbase.account_history_api.get_account_history: hafah + appbase.account_history_api.get_ops_in_block: hafah + appbase.account_history_api.enum_virtual_ops: hafah + appbase.account_history_api.get_transaction: hafah + appbase.condenser_api.get_account_history: hafah + appbase.condenser_api.get_ops_in_block: hafah + appbase.condenser_api.enum_virtual_ops: hafah + appbase.condenser_api.get_transaction: hafah + appbase.condenser_api.get_active_votes: hivemind + appbase.condenser_api.get_blog: hivemind + appbase.condenser_api.get_blog_entries: hivemind + appbase.condenser_api.get_comment_discussions_by_payout: hivemind + appbase.condenser_api.get_content: hivemind + appbase.condenser_api.get_content_replies: hivemind + appbase.condenser_api.get_discussions_by_author_before_date: hivemind + appbase.condenser_api.get_discussions_by_blog: hivemind + appbase.condenser_api.get_discussions_by_comments: hivemind + appbase.condenser_api.get_discussions_by_created: hivemind + appbase.condenser_api.get_discussions_by_feed: hivemind + appbase.condenser_api.get_discussions_by_hot: hivemind + appbase.condenser_api.get_discussions_by_promoted: hivemind + appbase.condenser_api.get_discussions_by_trending: hivemind + appbase.condenser_api.get_follow_count: hivemind + appbase.condenser_api.get_followers: hivemind + appbase.condenser_api.get_following: hivemind + appbase.condenser_api.get_post_discussions_by_payout: hivemind + appbase.condenser_api.get_reblogged_by: hivemind + appbase.condenser_api.get_replies_by_last_update: hivemind + appbase.condenser_api.get_trending_tags: hivemind + appbase.database_api.list_comments: hivemind + appbase.database_api.list_votes: hivemind + appbase.database_api.find_votes: hivemind + appbase.database_api.find_comments: hivemind + appbase.tags_api.get_discussion: hivemind + appbase.condenser_api.get_state: hivemind + +# TTLs can have the values NO_EXPIRE, NO_CACHE, EXPIRE_IF_REVERSIBLE, +# or a positive integer number of seconds to cache the result. +# +# EXPIRE_IF_REVERSIBLE will act as if the TTL is 9 seconds if the data +# is reversible, and forever if the data is irreversible. +# Drone must know how to get the block number out of the response for +# the call for EXPIRE_IF_REVERSIBLE to work, otherwise it will treat +# this as NO_CACHE. Currently Drone knows how to decode get_block & +# get_block_header responses +ttls: + hived: 3 + hived.login_api: NO_CACHE + hived.network_broadcast_api: NO_CACHE + hived.follow_api: 10 + hived.market_history_api: 1 + hived.database_api: 3 + hived.database_api.get_block: EXPIRE_IF_REVERSIBLE + hived.database_api.get_block_header: EXPIRE_IF_REVERSIBLE + hived.database_api.get_content: 1 + hived.database_api.get_state: 1 + # these were in the jussi config, but I don't think jussi was able to match on params. Drone isn't either. + # hived.database_api.get_state.params=['/trending']: 30 + # hived.database_api.get_state.params=['trending']: 30 + # hived.database_api.get_state.params=['/hot']: 30 + # hived.database_api.get_state.params=['/welcome']: 30 + # hived.database_api.get_state.params=['/promoted']: 30 + # hived.database_api.get_state.params=['/created']: 10 + hived.database_api.get_dynamic_global_properties: 1 + appbase: 1 + appbase.block_api: EXPIRE_IF_REVERSIBLE + appbase.block_api.get_block_range: NO_CACHE + appbase.database_api: 1 + appbase.condenser_api.get_account_reputations: 3600 + appbase.condenser_api.get_block: EXPIRE_IF_REVERSIBLE + appbase.condenser_api.get_ticker: 1 + appbase.condenser_api.get_accounts: 6 + appbase.condenser_api.get_account_history: 6 + appbase.condenser_api.get_content: 6 + appbase.condenser_api.get_profile: 6 + appbase.database_api.find_accounts: 3 + appbase.condenser_api.get_dynamic_global_properties: 1 + hive: NO_CACHE + bridge: NO_CACHE + bridge.get_discussion: 6 + bridge.get_account_posts: 12 + bridge.get_ranked_posts: 6 + bridge.get_profile: 6 + bridge.get_community: 6 + bridge.get_post: 6 + bridge.get_trending_topics: 3 + hafsql: NO_CACHE + +# how long to wait for the backend to respond before giving up +timeouts: + bridge: 30 + hafsql: 30 + hive: 30 + hived: 5 + hived.network_broadcast_api: 0 + appbase: 3 + appbase.chain_api.push_block: 0 + appbase.chain_api.push_transaction: 0 + appbase.network_broadcast_api: 0 + appbase.condenser_api.broadcast_block: 0 + appbase.condenser_api.broadcast_transaction: 0 + appbase.condenser_api.broadcast_transaction_synchronous: 0 + # appbase.condenser_api.get_ops_in_block.params=[2889020,false]: 20 + +# method rewriting rules. There are a few API calls where you can get the +# same result by calling several different methods. By default, Drone will +# only share cache entries between calls where the method name and parameters +# match. So, Drone would treat database_api.get_block(1) and block_api.get_block(1) +# as two completely different calls, and our cache hit rate would suffer. +# Entries in `equivalent_methods` will cause Drone to replace the method name +# so these calls are treated as one. +# +# destination_api.destination_method: +# - appbase.source_api.source_method1 +# - appbase.source_api.source_method2 +# +# Note, in this section, the source methods are in their full form (including namespace), +# while the destination method should be the method as it would be called in the +# json-rpc-request. +equivalent_methods: + destination_api.destination_method: + - appbase.source_api.source_method + # these are similar, but the block_api version wraps the result in an extra block{} + #block_api.get_block: + # - appbase.condenser_api.get_block + # need to check the rest of these to see if they're exactly equivalents + #block_api.get_block_header: + # - condenser_api.get_block_header + # - database_api.get_block_header + # these are similar, but I believe they have different asset representations + #account_history_api.get_ops_in_block: + # - appbase.condenser_api.get_ops_in_block + #account_history_api.get_transaction: + # - appbase.condenser_api.get_transaction diff --git a/src/main.rs b/src/main.rs index 2e8e125a71aa206a7be6856a75d2b93693c6de3f..41fcac4b51eb7c2882c9942c605d3e49384fa363 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,12 @@ use actix_cors::Cors; -use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder}; +use actix_web::{web, App, HttpRequest, HttpResponse, HttpResponseBuilder, HttpServer, Responder, http::StatusCode}; use moka::{future::Cache, Expiry}; use reqwest::{Client, ClientBuilder}; use serde::{Deserialize, Serialize, Serializer}; -use serde_json::Value; +use serde_json::{Value, json}; use std::sync::{Arc}; use std::time::{Duration, Instant, SystemTime}; -use actix_web::rt::time::sleep; +//use actix_web::rt::time::sleep; use tokio::sync::RwLock; use chrono::DateTime; @@ -20,6 +20,7 @@ use method_renamer::MethodAndParams; const DRONE_VERSION: &str = env!("CARGO_PKG_VERSION"); + struct BlockchainState { last_irreversible_block_number: u32, head_block_number: u32, @@ -36,7 +37,6 @@ impl BlockchainState { } } - #[derive(Serialize, Deserialize, Debug)] struct HealthCheck { status: String, @@ -99,43 +99,67 @@ impl Serialize for ErrorField { } } -#[derive(Clone, Debug)] -struct ErrorData { - code: i32, - message: String, - error: ErrorField, +// data returned just for logging/debugging +#[derive(Clone,Debug)] +struct ResponseTrackingInfo { + cached: bool, + mapped_method: MethodAndParams, // the method, parsed and transformed + backend_url: Option<String>, + upstream_method: Option<String> } -// Structure for the error response. -#[derive(Serialize, Deserialize, Debug, Clone)] -struct ErrorStructure { - jsonrpc: String, - id: ID, - code: i32, - message: String, - error: ErrorField, +impl ResponseTrackingInfo { + fn to_headers(self, reply_builder: &mut HttpResponseBuilder) { + reply_builder.insert_header(("X-Jussi-Cache-Hit", self.cached.to_string())); + reply_builder.insert_header(("X-Jussi-Namespace", self.mapped_method.namespace)); + reply_builder.insert_header(("X-Jussi-Api", self.mapped_method.api.unwrap_or("<Empty>".to_string()))); + reply_builder.insert_header(("X-Jussi-Method", self.mapped_method.method)); + reply_builder.insert_header(("X-Jussi-Params", self.mapped_method.params.map_or("[]".to_string(), |v| v.to_string()))); + if self.backend_url.is_some() { + reply_builder.insert_header(("X-Jussi-Backend-Url", self.backend_url.unwrap())); + } + if self.upstream_method.is_some() { + reply_builder.insert_header(("X-Jussi-Upstream-Method", self.upstream_method.unwrap())); + } + } +} + +// ErrorData and ApiCallResponseData are the values stored in the cache. It's +// everything about a reply that isn't specific to the caller (i.e., not the +// `jsonrpc` and `id` fields) +#[derive(Clone, Debug)] +struct ErrorData { + error: Value, + http_status: StatusCode, + tracking_info: Option<ResponseTrackingInfo> } #[derive(Clone, Debug)] struct ApiCallResponseData { result: Value, - // the following fields are for emitting debugging headers, not strictly necessary: - backend_url: String, - upstream_method: String + tracking_info: Option<ResponseTrackingInfo> +} + +// The full error and response structures, including caller-specific data +#[derive(Debug, Clone)] +struct ErrorStructure { + jsonrpc: String, + id: u32, + error: Value, + http_status: StatusCode, + tracking_info: Option<ResponseTrackingInfo> } #[derive(Clone)] struct APICallResponse { /// the original value of jsonrpc request made by the caller (usually "2.0") jsonrpc: String, - result: Value, /// the id the caller used in their request id: ID, - // data returned just for logging/debugging - cached: bool, - mapped_method: MethodAndParams, // the method, parsed and transformed - backend_url: String, - upstream_method: String + + result: Value, + + tracking_info: Option<ResponseTrackingInfo> } #[derive(Debug, Copy, Clone)] @@ -244,9 +268,18 @@ async fn request_from_upstream(data: web::Data<AppData>, mapped_method: MethodAn None => { return CacheEntry { result: Err(ErrorData { - code: -32603, // or 32601? - message: "Unable to map request to endpoint.".to_string(), - error: ErrorField::Message("Unable to map request to endpoint.".to_string()), + error: json!({ + "code": -32603, // or 32601? + "message": "Unable to map request to endpoint.", + "error": "Unable to map request to endpoint." + }), + http_status: StatusCode::NOT_FOUND, + tracking_info: Some(ResponseTrackingInfo { + cached: false, + mapped_method, + backend_url: None, + upstream_method: None + }) }), size: 0, ttl: CacheTtl::NoCache @@ -260,6 +293,13 @@ async fn request_from_upstream(data: web::Data<AppData>, mapped_method: MethodAn let client = data.webclient.clone(); + let tracking_info = Some(ResponseTrackingInfo { + cached: false, + mapped_method, + backend_url: Some(endpoint.to_string()), + upstream_method: Some(upstream_request.method.clone()) + }); + // Send the request to the endpoints. let res = match client .post(endpoint) @@ -273,9 +313,13 @@ async fn request_from_upstream(data: web::Data<AppData>, mapped_method: MethodAn error_message.push_str(&endpoint.to_string()); return CacheEntry { result: Err(ErrorData { - code: -32700, - message: "Unable to send request to endpoint.".to_string(), - error: ErrorField::Message(error_message), + error: json!({ + "code": -32700, + "message": "Unable to send request to endpoint.", + "error": error_message + }), + http_status: StatusCode::SERVICE_UNAVAILABLE, + tracking_info }), size: 0, ttl: CacheTtl::NoCache @@ -284,16 +328,20 @@ async fn request_from_upstream(data: web::Data<AppData>, mapped_method: MethodAn }; // to simulate slow calls, put a sleep here - sleep(Duration::from_secs(10)).await; + // sleep(Duration::from_secs(10)).await; let body = match res.text().await { Ok(text) => text, Err(err) => { return CacheEntry { result: Err(ErrorData { - code: -32600, - message: "Received an invalid response from the endpoint.".to_string(), - error: ErrorField::Message(err.to_string()), + error: json!({ + "code": -32600, + "message": "Received an invalid response from the endpoint.", + "error": err.to_string(), + }), + http_status: StatusCode::INTERNAL_SERVER_ERROR, + tracking_info }), size: 0, ttl: CacheTtl::NoCache @@ -305,9 +353,13 @@ async fn request_from_upstream(data: web::Data<AppData>, mapped_method: MethodAn Err(err) => { return CacheEntry { result: Err(ErrorData { - code: -32602, - message: "Unable to parse endpoint data.".to_string(), - error: ErrorField::Message(err.to_string()), + error: json!({ + "code": -32602, + "message": "Unable to parse endpoint data.", + "error": err.to_string(), + }), + http_status: StatusCode::INTERNAL_SERVER_ERROR, + tracking_info }), size: 0, ttl: CacheTtl::NoCache @@ -317,9 +369,9 @@ async fn request_from_upstream(data: web::Data<AppData>, mapped_method: MethodAn if json_body["error"].is_object() { return CacheEntry { result: Err(ErrorData { - code: -32700, - message: "Endpoint returned an error.".to_string(), - error: ErrorField::Object(json_body["error"].clone()), + error: json_body["error"].take(), + http_status: StatusCode::OK, + tracking_info }), size: 0, ttl: CacheTtl::NoCache @@ -327,8 +379,10 @@ async fn request_from_upstream(data: web::Data<AppData>, mapped_method: MethodAn } // if the call was to get_dynamic_global_properties, save off the last irreversible block - println!("Mapped method is {}", mapped_method.method); - if mapped_method.method == "get_dynamic_global_properties" { + let mapped_method_ref = &tracking_info.as_ref().unwrap().mapped_method; + let method_name_only = &mapped_method_ref.method; + println!("Mapped method is {}", method_name_only); + if method_name_only == "get_dynamic_global_properties" { println!("This method was get_dynamic_global_properties"); let new_lib = json_body["result"]["last_irreversible_block_num"].as_u64().map(|v| v as u32); @@ -364,7 +418,7 @@ async fn request_from_upstream(data: web::Data<AppData>, mapped_method: MethodAn } else { - let ttl_from_config = *data.config.lookup_ttl(mapped_method.get_method_name_parts()).unwrap_or(&TtlValue::NoCache); + let ttl_from_config = *data.config.lookup_ttl(mapped_method_ref.get_method_name_parts()).unwrap_or(&TtlValue::NoCache); println!("lookup_ttl for {method_and_params_str} returns {ttl_from_config:?}"); match ttl_from_config { @@ -390,8 +444,7 @@ async fn request_from_upstream(data: web::Data<AppData>, mapped_method: MethodAn CacheEntry { result: Ok(ApiCallResponseData { result: json_body["result"].take(), - backend_url: endpoint.to_string(), - upstream_method: upstream_request.method.to_string() + tracking_info }), size: body.len() as u32, ttl @@ -400,14 +453,18 @@ async fn request_from_upstream(data: web::Data<AppData>, mapped_method: MethodAn async fn handle_request(request: APIRequest, data: &web::Data<AppData>, client_ip: &String) -> Result<APICallResponse, ErrorStructure> { // perform any requested mappings, this may give us different method names & and params - let mapped_method = method_renamer::map_method_name(&data.config, &request.method, &request.params).or_else(|_| - Err(ErrorStructure { + let mapped_method = method_renamer::map_method_name(&data.config, &request.method, &request.params).map_err(|_| + ErrorStructure { jsonrpc: request.jsonrpc.clone(), id : request.id.clone(), - code: -32700, - message: "Unable to parse request method.".to_string(), - error: ErrorField::Message("Unable to parse request method.".to_string()), - }) + error: json!({ + "code": -32700, + "message": "Unable to parse request method.", + "error": "Unable to parse request method." + }), + http_status: StatusCode::NOT_FOUND, + tracking_info: None + } )?; check_for_future_block_requests(&mapped_method, data).await; @@ -456,24 +513,29 @@ async fn handle_request(request: APIRequest, data: &web::Data<AppData>, client_i match cache_entry.result { Ok(api_call_response) => { - Ok(APICallResponse { - jsonrpc: request.jsonrpc.clone(), - result: api_call_response.result.clone(), + let mut response = APICallResponse { + jsonrpc: request.jsonrpc, id: request.id, - cached: !upstream_was_called, - mapped_method, - backend_url: api_call_response.backend_url.to_string(), - upstream_method: api_call_response.upstream_method.to_string() - }) + result: api_call_response.result, + tracking_info: api_call_response.tracking_info + }; + if response.tracking_info.is_some() { + response.tracking_info.as_mut().unwrap().cached = !upstream_was_called; + } + Ok(response) } Err(error_data) => { - Err(ErrorStructure { + let mut response = ErrorStructure { jsonrpc: request.jsonrpc.clone(), id : request.id, - code: error_data.code, - message: error_data.message.to_string(), - error: error_data.error.clone() - }) + error: error_data.error, + http_status: error_data.http_status, + tracking_info: error_data.tracking_info + }; + if response.tracking_info.is_some() { + response.tracking_info.as_mut().unwrap().cached = !upstream_was_called; + } + Err(response) } } } @@ -492,13 +554,15 @@ async fn api_call( let user_ip = match client_ip { Ok(ip) => ip, Err(_) => { - return HttpResponse::InternalServerError().json(ErrorStructure { - jsonrpc: "2.0".to_string(), - id: ID::Int(0), - code: -32000, - message: "Internal Server Error".to_string(), - error: ErrorField::Message("Invalid Cloudflare Proxy Header.".to_string()), - }) + return HttpResponse::InternalServerError().json(json!({ + "jsonrpc": "2.0", + "id": 0, + "error": { + "code": -32000, + "message": "Internal Server Error", + "error": "Invalid Cloudflare Proxy Header." + } + })) } }; @@ -506,60 +570,74 @@ async fn api_call( APICall::Single(request) => { let result = handle_request(request, &data, &user_ip).await; match result { - Ok(response) => HttpResponse::Ok() - .insert_header(("Drone-Version", DRONE_VERSION)) - .insert_header(("X-Jussi-Cache-Hit", response.cached.to_string())) - .insert_header(("X-Jussi-Namespace", response.mapped_method.namespace)) - .insert_header(("X-Jussi-Api", response.mapped_method.api.unwrap_or("<Empty>".to_string()))) - .insert_header(("X-Jussi-Method", response.mapped_method.method)) - .insert_header(("X-Jussi-Params", response.mapped_method.params.map_or("[]".to_string(), |v| v.to_string()))) - .insert_header(("X-Jussi-Backend-Url", response.backend_url)) - .insert_header(("X-Jussi-Upstream-Method", response.upstream_method)) - .json(serde_json::json!({ + Ok(response) => { + let mut reply_builder = HttpResponse::Ok(); + reply_builder.insert_header(("Drone-Version", DRONE_VERSION)); + if let Some(tracking_info) = response.tracking_info { + tracking_info.to_headers(&mut reply_builder); + } + reply_builder.json(serde_json::json!({ "jsonrpc": response.jsonrpc, "result": response.result, "id": response.id, - })), - Err(err) => HttpResponse::InternalServerError().json(err), + })) + }, + Err(err) => { + let mut reply_builder = HttpResponse::build(err.http_status); + reply_builder.insert_header(("Drone-Version", DRONE_VERSION)); + if let Some(tracking_info) = err.tracking_info { + tracking_info.to_headers(&mut reply_builder); + } + reply_builder.json(json!({ + "jsonrpc": err.jsonrpc, + "id": err.id, + "error": err.error + })) + } } } APICall::Batch(requests) => { - let mut responses = Vec::new(); if requests.len() > 100 { - return HttpResponse::InternalServerError().json(ErrorStructure { - jsonrpc: "2.0".to_string(), - id: ID::Int(0), - code: -32600, - message: "Request parameter error.".to_string(), - error: ErrorField::Message( - "Batch size too large, maximum allowed is 100.".to_string(), - ), - }); + return HttpResponse::InternalServerError().json(json!({ + "jsonrpc": "2.0".to_string(), + "id": 0, + "error": json!({ + "code": -32600, + "message": "Request parameter error.", + "error": "Batch size too large, maximum allowed is 100." + }), + })); } + let mut responses = Vec::new(); + // we'll say that the result was cached if all non-error responses came from the cache. + // the "cached" property isn't particularly useful for batch requests, so don't + // overthink it + let mut cached = true; for request in requests { let result = handle_request(request, &data, &user_ip).await; match result { - Ok(response) => responses.push(response), - Err(err) => return HttpResponse::InternalServerError().json(err), - } - } - let mut cached = true; - let mut result = Vec::new(); - for response in responses { - if !response.cached { - cached = false; + Ok(response) => { + if !response.tracking_info.map_or(false, |v| v.cached) { + cached = false; + } + responses.push(json!({ + "jsonrpc": response.jsonrpc, + "result": response.result, + "id": response.id, + })) + }, + Err(err) => responses.push(json!({ + "jsonrpc": err.jsonrpc, + "id": err.id, + "error": err.error + })) } - result.push(serde_json::json!({ - "jsonrpc": response.jsonrpc, - "result": response.result, - "id": response.id, - })); } HttpResponse::Ok() .insert_header(("Drone-Version", DRONE_VERSION)) .insert_header(("Cache-Status", cached.to_string())) - .json(serde_json::Value::Array(result)) + .json(serde_json::Value::Array(responses)) } } } diff --git a/src/method_renamer.rs b/src/method_renamer.rs index ee0a996594bba7dfc53f72e4e7d3f05c40cc5029..807fe7b35e147f816320488ebfab292aa6d34dd4 100644 --- a/src/method_renamer.rs +++ b/src/method_renamer.rs @@ -10,7 +10,8 @@ pub struct MethodAndParams { pub namespace: String, pub api: Option<String>, pub method: String, - pub params: Option<Value> + pub params: Option<Value>, + pub full_method: String // the method as called (usually api.method) } impl MethodAndParams { @@ -39,7 +40,7 @@ impl MethodAndParams { APIRequest { id: 1, jsonrpc: "2.0".to_string(), - method: self.api.as_ref().map_or("".to_string(), |api| api.clone() + ".") + &self.method, + method: self.full_method.to_string(), params: self.params.clone() } } @@ -63,19 +64,29 @@ fn decode_api_if_numeric(api: &Value) -> Result<String, String> { fn parse_call(params: &Option<Value>) -> Result<MethodAndParams, String> { match params.as_ref().and_then(Value::as_array).map(Vec::as_slice) { Some([api, method]) => { + let api_part = decode_api_if_numeric(api)?; + let method_part = method.as_str().ok_or_else(|| "Invalid method name".to_string())?; + let full_method = api_part.to_string() + "." + method_part; + Ok(MethodAndParams { namespace: "appbase".to_string(), - api: Some(decode_api_if_numeric(api)?), - method: method.as_str().ok_or("Invalid method name".to_string())?.to_string(), - params: None + api: Some(api_part), + method: method_part.to_string(), + params: None, + full_method }) } Some([api, method, actual_params]) => { + let api_part = decode_api_if_numeric(api)?; + let method_part = method.as_str().ok_or_else(|| "Invalid method name".to_string())?; + let full_method = api_part.to_string() + "." + method_part; + Ok(MethodAndParams { namespace: if api == "condenser_api" || api == "jsonrpc" || actual_params.is_object() { "appbase".to_string() } else { "hived".to_string() }, - api: Some(decode_api_if_numeric(api)?), - method: method.as_str().ok_or("Invalid method name".to_string())?.to_string(), - params: Some(actual_params.clone()) + api: Some(api_part), + method: method_part.to_string(), + params: Some(actual_params.clone()), + full_method }) } _ => { @@ -84,8 +95,8 @@ fn parse_call(params: &Option<Value>) -> Result<MethodAndParams, String> { } } -fn convert_method_name(method: &str, params: &Option<Value>) -> Result<MethodAndParams, String> { - let parts: Vec<&str> = method.split('.').collect(); +fn convert_method_name(full_method: &str, params: &Option<Value>) -> Result<MethodAndParams, String> { + let parts: Vec<&str> = full_method.split('.').collect(); match &parts[..] { ["call"] => { parse_call(params) @@ -95,7 +106,8 @@ fn convert_method_name(method: &str, params: &Option<Value>) -> Result<MethodAnd namespace: "hived".to_string(), api: Some("database_api".to_string()), method: bare_method.to_string(), - params: params.clone() + params: params.clone(), + full_method: full_method.to_string() }) } [appbase_api, method] if appbase_api.ends_with("_api") => { @@ -104,7 +116,8 @@ fn convert_method_name(method: &str, params: &Option<Value>) -> Result<MethodAnd namespace: "appbase".to_string(), api: Some(appbase_api.to_string()), method: method.to_string(), - params: params.clone() + params: params.clone(), + full_method: full_method.to_string() }) } ["jsonrpc", method] => { @@ -112,7 +125,8 @@ fn convert_method_name(method: &str, params: &Option<Value>) -> Result<MethodAnd namespace: "appbase".to_string(), api: Some("jsonrpc".to_string()), method: method.to_string(), - params: params.clone() + params: params.clone(), + full_method: full_method.to_string() }) } [namespace, method] => { @@ -122,7 +136,8 @@ fn convert_method_name(method: &str, params: &Option<Value>) -> Result<MethodAnd namespace: namespace.to_string(), api: None, method: method.to_string(), - params: params.clone() + params: params.clone(), + full_method: full_method.to_string() }) } [namespace, api, method] => { @@ -130,7 +145,8 @@ fn convert_method_name(method: &str, params: &Option<Value>) -> Result<MethodAnd namespace: namespace.to_string(), api: Some(api.to_string()), method: method.to_string(), - params: params.clone() + params: params.clone(), + full_method: full_method.to_string() }) } _ => {