Skip to main content

tidepool_server/
rest.rs

1//! REST transport. Mirrors the paths `helius-sdk` hits on
2//! `api.helius.xyz/v0/...` — served from the same base URL as our
3//! JSON-RPC endpoint so a user pointing `helius-sdk` at Tidepool
4//! gets every method, on the transport the SDK expects.
5//!
6//! Parity rule: if Helius serves a method over REST, we serve it
7//! over REST. No method lives on both transports — clients should be
8//! unable to write local code that'd fail against real Helius.
9//!
10//! Implementation: each REST route synthesizes a `JsonRpcRequest`
11//! internally, calls the shared handler function from `dispatcher.rs`,
12//! and unwraps the result (or surfaces the JSON-RPC error as a REST
13//! error body + appropriate HTTP status). Lets us keep all business
14//! logic in one place.
15
16use std::sync::Arc;
17
18use axum::{
19    extract::Path,
20    http::StatusCode,
21    response::{IntoResponse, Response},
22    routing::{get, post},
23    Extension, Json, Router,
24};
25use serde_json::{json, Value};
26
27use tidepool_rpc::cache::CacheStore;
28use tidepool_rpc::cnft::CnftStore;
29use tidepool_rpc::upstream::UpstreamClient;
30
31use crate::dispatcher::{
32    handle_create_webhook, handle_delete_webhook, handle_edit_webhook, handle_get_all_webhooks,
33    handle_get_balances, handle_get_transactions, handle_get_transactions_by_address,
34    handle_get_webhook_by_id, Ctx,
35};
36use crate::json_rpc::{codes, JsonRpcRequest};
37
38type RestCtx = Ctx<dyn CnftStore, dyn CacheStore, dyn UpstreamClient>;
39
40/// Mount the REST routes. Paths mirror Helius's public REST API
41/// exactly so a redirected base URL drops straight in.
42///
43/// The `Arc<RestCtx>` is injected via `axum::Extension` at the parent
44/// router level, not baked in here — keeps the router state-type-
45/// agnostic so it composes with any parent that can layer the
46/// extension. Axum 0.8 idiomatic pattern for shared state.
47pub fn router<S: Clone + Send + Sync + 'static>() -> Router<S> {
48    Router::new()
49        // Wallet API.
50        .route("/v0/addresses/{address}/balances", get(get_balances_rest))
51        // Enhanced Transactions.
52        .route(
53            "/v0/addresses/{address}/transactions",
54            get(get_transactions_by_address_rest),
55        )
56        .route("/v0/transactions", post(get_transactions_rest))
57        // Webhooks CRUD.
58        .route(
59            "/v0/webhooks",
60            get(list_webhooks_rest).post(create_webhook_rest),
61        )
62        .route(
63            "/v0/webhooks/{id}",
64            get(get_webhook_rest)
65                .put(edit_webhook_rest)
66                .delete(delete_webhook_rest),
67        )
68}
69
70// ─── per-route handlers ────────────────────────────────────────────
71// Each synthesizes a JSON-RPC-shaped request, delegates to the shared
72// handler from `dispatcher.rs`, and returns a REST-shape response
73// body (plain result; no JSON-RPC envelope).
74
75async fn get_balances_rest(
76    Path(address): Path<String>,
77    Extension(ctx): Extension<Arc<RestCtx>>,
78) -> Response {
79    let req = synth_request("getBalances", json!([address]));
80    let resp = handle_get_balances(&*ctx, &req).await;
81    rest_response_from_rpc(resp)
82}
83
84async fn get_transactions_by_address_rest(
85    Path(address): Path<String>,
86    Extension(ctx): Extension<Arc<RestCtx>>,
87) -> Response {
88    let req = synth_request("getTransactionsByAddress", json!({ "address": address }));
89    let resp = handle_get_transactions_by_address(&*ctx, &req).await;
90    rest_response_from_rpc(resp)
91}
92
93async fn get_transactions_rest(
94    Extension(ctx): Extension<Arc<RestCtx>>,
95    Json(body): Json<Value>,
96) -> Response {
97    // REST body is typically `{ "transactions": ["sig1", "sig2"] }`;
98    // reshape to the JSON-RPC `signatures` key our handler expects.
99    let sigs = body
100        .get("transactions")
101        .cloned()
102        .or_else(|| body.get("signatures").cloned())
103        .unwrap_or(Value::Array(Vec::new()));
104    let req = synth_request("getTransactions", json!({ "signatures": sigs }));
105    let resp = handle_get_transactions(&*ctx, &req).await;
106    rest_response_from_rpc(resp)
107}
108
109async fn create_webhook_rest(
110    Extension(ctx): Extension<Arc<RestCtx>>,
111    Json(body): Json<Value>,
112) -> Response {
113    let req = synth_request("createWebhook", body);
114    let resp = handle_create_webhook(&*ctx, &req).await;
115    rest_response_from_rpc(resp)
116}
117
118async fn list_webhooks_rest(Extension(ctx): Extension<Arc<RestCtx>>) -> Response {
119    let req = synth_request("getAllWebhooks", Value::Null);
120    let resp = handle_get_all_webhooks(&*ctx, &req).await;
121    rest_response_from_rpc(resp)
122}
123
124async fn get_webhook_rest(
125    Path(id): Path<String>,
126    Extension(ctx): Extension<Arc<RestCtx>>,
127) -> Response {
128    let req = synth_request("getWebhookByID", json!({ "webhookID": id }));
129    let resp = handle_get_webhook_by_id(&*ctx, &req).await;
130    rest_response_from_rpc(resp)
131}
132
133async fn edit_webhook_rest(
134    Path(id): Path<String>,
135    Extension(ctx): Extension<Arc<RestCtx>>,
136    Json(mut body): Json<Value>,
137) -> Response {
138    // Fold the URL id into the body so the handler sees both.
139    if let Value::Object(ref mut map) = body {
140        map.insert("webhookID".into(), Value::String(id.clone()));
141    } else {
142        body = json!({ "webhookID": id });
143    }
144    let req = synth_request("editWebhook", body);
145    let resp = handle_edit_webhook(&*ctx, &req).await;
146    rest_response_from_rpc(resp)
147}
148
149async fn delete_webhook_rest(
150    Path(id): Path<String>,
151    Extension(ctx): Extension<Arc<RestCtx>>,
152) -> Response {
153    let req = synth_request("deleteWebhook", json!({ "webhookID": id }));
154    let resp = handle_delete_webhook(&*ctx, &req).await;
155    rest_response_from_rpc(resp)
156}
157
158// ─── helpers ───────────────────────────────────────────────────────
159
160fn synth_request(method: &str, params: Value) -> JsonRpcRequest {
161    JsonRpcRequest {
162        jsonrpc: Some("2.0".into()),
163        id: Value::from(0),
164        method: method.into(),
165        params,
166    }
167}
168
169/// Unwrap the internal JSON-RPC response into REST shape. Success
170/// returns the `result` field (or the whole thing if there's no
171/// envelope); error surfaces as JSON body with a 4xx/5xx status.
172fn rest_response_from_rpc(mut resp: Value) -> Response {
173    if let Some(err) = resp.get_mut("error").map(Value::take) {
174        let code = err
175            .get("code")
176            .and_then(Value::as_i64)
177            .unwrap_or(i64::from(codes::INTERNAL_ERROR));
178        // Map JSON-RPC error codes to HTTP status. -32602 (Invalid
179        // params) → 400, everything else → 500.
180        let status = match code {
181            -32602 => StatusCode::BAD_REQUEST,
182            -32601 => StatusCode::NOT_FOUND,
183            _ => StatusCode::INTERNAL_SERVER_ERROR,
184        };
185        return (status, Json(err)).into_response();
186    }
187    let result = resp.get_mut("result").map_or(Value::Null, Value::take);
188    (StatusCode::OK, Json(result)).into_response()
189}