Skip to main content

forest/rpc/reflect/
mod.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4//! Forest wishes to provide [OpenRPC](http://open-rpc.org) definitions for
5//! Filecoin APIs.
6//! To do this, it needs:
7//! - [JSON Schema](https://json-schema.org/) definitions for all the argument
8//!   and return types.
9//! - The number of arguments ([arity](https://en.wikipedia.org/wiki/Arity)) and
10//!   names of those arguments for each RPC method.
11//!
12//! As a secondary objective, we wish to provide an RPC client for our CLI, and
13//! internal tests against Lotus.
14//!
15//! The [`RpcMethod`] trait encapsulates all the above at a single site.
16//! - [`schemars::JsonSchema`] provides schema definitions,
17//! - [`RpcMethod`] defining arity and actually dispatching the function calls.
18
19pub mod jsonrpc_types;
20
21mod parser;
22mod util;
23
24use crate::lotus_json::HasLotusJson;
25
26use self::{jsonrpc_types::RequestParameters, util::Optional as _};
27use super::error::ServerError as Error;
28use ahash::HashMap;
29use anyhow::Context as _;
30use enumflags2::{BitFlags, bitflags, make_bitflags};
31use http::{Extensions, Uri};
32use jsonrpsee::RpcModule;
33use openrpc_types::{ContentDescriptor, Method, ParamStructure, ReferenceOr};
34use parser::Parser;
35use schemars::{JsonSchema, Schema, SchemaGenerator};
36use serde::{
37    Deserialize, Serialize,
38    de::{Error as _, Unexpected},
39};
40use std::{future::Future, str::FromStr, sync::Arc};
41use strum::EnumString;
42
43/// Type to be used by [`RpcMethod::handle`].
44pub type Ctx = Arc<crate::rpc::RPCState>;
45
46/// A definition of an RPC method handler which:
47/// - can be [registered](RpcMethodExt::register) with an [`RpcModule`].
48/// - can describe itself in OpenRPC.
49///
50/// Note, an earlier draft of this trait had an additional type parameter for `Ctx`
51/// for generality.
52/// However, fixing it as [`Ctx<...>`] saves on complexity/confusion for implementors,
53/// at the expense of handler flexibility, which could come back to bite us.
54/// - All handlers accept the same type.
55/// - All `Ctx`s must be `Send + Sync + 'static` due to bounds on [`RpcModule`].
56/// - Handlers don't specialize on top of the given bounds, but they MAY relax them.
57pub trait RpcMethod<const ARITY: usize> {
58    /// Number of required parameters, defaults to `ARITY`.
59    const N_REQUIRED_PARAMS: usize = ARITY;
60    /// Method name.
61    const NAME: &'static str;
62    /// Alias for `NAME`. Note that currently this is not reflected in the OpenRPC spec.
63    const NAME_ALIAS: Option<&'static str> = None;
64    /// Name of each argument, MUST be unique.
65    const PARAM_NAMES: [&'static str; ARITY];
66    /// See [`ApiPaths`].
67    const API_PATHS: BitFlags<ApiPaths>;
68    /// See [`Permission`]
69    const PERMISSION: Permission;
70    /// Becomes [`openrpc_types::Method::summary`].
71    const SUMMARY: Option<&'static str> = None;
72    /// Becomes [`openrpc_types::Method::description`].
73    const DESCRIPTION: &'static str;
74    /// Types of each argument. [`Option`]-al arguments MUST follow mandatory ones.
75    type Params: Params<ARITY>;
76    /// Return value of this method.
77    type Ok: HasLotusJson;
78    /// Logic for this method.
79    fn handle(
80        ctx: Ctx,
81        params: Self::Params,
82        ext: &Extensions,
83    ) -> impl Future<Output = Result<Self::Ok, Error>> + Send;
84    /// If it a subscription method. Defaults to false.
85    const SUBSCRIPTION: bool = false;
86}
87
88/// The permission required to call an RPC method.
89#[derive(
90    Debug,
91    Clone,
92    Copy,
93    PartialEq,
94    Eq,
95    PartialOrd,
96    Ord,
97    Hash,
98    derive_more::Display,
99    Serialize,
100    Deserialize,
101)]
102#[serde(rename_all = "snake_case")]
103pub enum Permission {
104    /// admin
105    Admin,
106    /// sign
107    Sign,
108    /// write
109    Write,
110    /// read
111    Read,
112}
113
114/// Which paths should this method be exposed on?
115///
116/// This information is important when using [`crate::rpc::client`].
117#[bitflags]
118#[repr(u8)]
119#[derive(
120    Debug,
121    Default,
122    Clone,
123    Copy,
124    Hash,
125    Eq,
126    PartialEq,
127    Ord,
128    PartialOrd,
129    clap::ValueEnum,
130    EnumString,
131    strum::Display,
132    Deserialize,
133    Serialize,
134)]
135pub enum ApiPaths {
136    /// Only expose this method on `/rpc/v0`
137    #[strum(ascii_case_insensitive)]
138    V0 = 0b00000001,
139    /// Only expose this method on `/rpc/v1`
140    #[strum(ascii_case_insensitive)]
141    #[default]
142    V1 = 0b00000010,
143    /// Only expose this method on `/rpc/v2`
144    #[strum(ascii_case_insensitive)]
145    V2 = 0b00000100,
146}
147
148impl ApiPaths {
149    pub const fn all() -> BitFlags<Self> {
150        // Not containing V2 until it's released in Lotus.
151        make_bitflags!(Self::{ V0 | V1 })
152    }
153
154    // Remove this helper once all RPC methods are migrated to V2.
155    // We're incrementally migrating methods to V2 support. Once complete,
156    // update all() to include V2 and remove this temporary helper.
157    pub const fn all_with_v2() -> BitFlags<Self> {
158        Self::all().union_c(make_bitflags!(Self::{ V2 }))
159    }
160
161    pub fn from_uri(uri: &Uri) -> anyhow::Result<Self> {
162        Ok(Self::from_str(uri.path().trim_start_matches("/rpc/"))?)
163    }
164
165    pub fn path(&self) -> &'static str {
166        match self {
167            Self::V0 => "rpc/v0",
168            Self::V1 => "rpc/v1",
169            Self::V2 => "rpc/v2",
170        }
171    }
172}
173
174/// Utility methods, defined as an extension trait to avoid having to specify
175/// `ARITY` in user code.
176pub trait RpcMethodExt<const ARITY: usize>: RpcMethod<ARITY> {
177    /// Convert from typed handler parameters to un-typed JSON-RPC parameters.
178    ///
179    /// Exposes errors from [`Params::unparse`]
180    fn build_params(
181        params: Self::Params,
182        calling_convention: ConcreteCallingConvention,
183    ) -> Result<RequestParameters, serde_json::Error> {
184        let args = params.unparse()?;
185        match calling_convention {
186            ConcreteCallingConvention::ByPosition => {
187                Ok(RequestParameters::ByPosition(Vec::from(args)))
188            }
189            ConcreteCallingConvention::ByName => Ok(RequestParameters::ByName(
190                itertools::zip_eq(Self::PARAM_NAMES.into_iter().map(String::from), args).collect(),
191            )),
192        }
193    }
194
195    fn parse_params(
196        params_raw: Option<impl AsRef<str>>,
197        calling_convention: ParamStructure,
198    ) -> anyhow::Result<Self::Params> {
199        Ok(Self::Params::parse(
200            params_raw
201                .map(|s| serde_json::from_str(s.as_ref()))
202                .transpose()?,
203            Self::PARAM_NAMES,
204            calling_convention,
205            Self::N_REQUIRED_PARAMS,
206        )?)
207    }
208
209    /// Generate a full `OpenRPC` method definition for this endpoint.
210    fn openrpc<'de>(
211        g: &mut SchemaGenerator,
212        calling_convention: ParamStructure,
213        method_name: &'static str,
214    ) -> Method
215    where
216        <Self::Ok as HasLotusJson>::LotusJson: JsonSchema + Deserialize<'de>,
217    {
218        Method {
219            name: String::from(method_name),
220            params: itertools::zip_eq(Self::PARAM_NAMES, Self::Params::schemas(g))
221                .enumerate()
222                .map(|(pos, (name, (schema, nullable)))| {
223                    let required = pos <= Self::N_REQUIRED_PARAMS;
224                    if !required && !nullable {
225                        panic!("Optional parameter at position {pos} should be of an optional type. method={method_name}, param_name={name}");
226                    }
227                    ReferenceOr::Item(ContentDescriptor {
228                        name: String::from(name),
229                        schema,
230                        required: Some(required),
231                        ..Default::default()
232                    })
233                })
234                .collect(),
235            param_structure: Some(calling_convention),
236            result: Some(ReferenceOr::Item(ContentDescriptor {
237                name: format!("{method_name}.Result"),
238                schema: g.subschema_for::<<Self::Ok as HasLotusJson>::LotusJson>(),
239                required: Some(!<Self::Ok as HasLotusJson>::LotusJson::optional()),
240                ..Default::default()
241            })),
242            summary: Self::SUMMARY.map(Into::into),
243            description: Some(Self::DESCRIPTION.into()),
244            ..Default::default()
245        }
246    }
247
248    /// Register a method with an [`RpcModule`].
249    fn register(
250        modules: &mut HashMap<ApiPaths, RpcModule<crate::rpc::RPCState>>,
251        calling_convention: ParamStructure,
252    ) -> Result<(), jsonrpsee::core::RegisterMethodError>
253    where
254        <Self::Ok as HasLotusJson>::LotusJson: Clone + 'static,
255    {
256        use clap::ValueEnum as _;
257
258        assert!(
259            Self::N_REQUIRED_PARAMS <= ARITY,
260            "N_REQUIRED_PARAMS({}) can not be greater than ARITY({ARITY}) in {}",
261            Self::N_REQUIRED_PARAMS,
262            Self::NAME
263        );
264
265        for api_version in ApiPaths::value_variants() {
266            if Self::API_PATHS.contains(*api_version)
267                && let Some(module) = modules.get_mut(api_version)
268            {
269                module.register_async_method(
270                    Self::NAME,
271                    move |params, ctx, extensions| async move {
272                        let params = Self::parse_params(params.as_str(), calling_convention)
273                            .map_err(|e| Error::invalid_params(e, None))?;
274                        let ok = Self::handle(ctx, params, &extensions).await?;
275                        Result::<_, jsonrpsee::types::ErrorObjectOwned>::Ok(ok.into_lotus_json())
276                    },
277                )?;
278                if let Some(alias) = Self::NAME_ALIAS {
279                    module.register_alias(alias, Self::NAME)?
280                }
281            }
282        }
283        Ok(())
284    }
285    /// Returns [`Err`] if any of the parameters fail to serialize.
286    fn request(params: Self::Params) -> serde_json::Result<crate::rpc::Request<Self::Ok>> {
287        // hardcode calling convention because lotus is by-position only
288        let params = Self::request_params(params)?;
289        Ok(crate::rpc::Request {
290            method_name: Self::NAME.into(),
291            params,
292            result_type: std::marker::PhantomData,
293            api_path: crate::rpc::Request::<Self::Ok>::max_api_path(Self::API_PATHS)
294                .map_err(serde_json::Error::custom)?,
295            timeout: *crate::rpc::DEFAULT_REQUEST_TIMEOUT,
296        })
297    }
298
299    fn request_params(params: Self::Params) -> serde_json::Result<serde_json::Value> {
300        // hardcode calling convention because lotus is by-position only
301        Ok(
302            match Self::build_params(params, ConcreteCallingConvention::ByPosition)? {
303                RequestParameters::ByPosition(mut it) => {
304                    // Omit optional parameters when they are null
305                    // This can be refactored into using `while pop_if`
306                    // when the API is stablized.
307                    while Self::N_REQUIRED_PARAMS < it.len() {
308                        match it.last() {
309                            Some(last) if last.is_null() => it.pop(),
310                            _ => break,
311                        };
312                    }
313                    serde_json::Value::Array(it)
314                }
315                RequestParameters::ByName(it) => serde_json::Value::Object(it),
316            },
317        )
318    }
319
320    /// Creates a request, using the alias method name if `use_alias` is `true`.
321    fn request_with_alias(
322        params: Self::Params,
323        use_alias: bool,
324    ) -> anyhow::Result<crate::rpc::Request<Self::Ok>> {
325        let params = Self::request_params(params)?;
326        let name = if use_alias {
327            Self::NAME_ALIAS.context("alias is None")?
328        } else {
329            Self::NAME
330        };
331
332        Ok(crate::rpc::Request {
333            method_name: name.into(),
334            params,
335            result_type: std::marker::PhantomData,
336            api_path: crate::rpc::Request::<Self::Ok>::max_api_path(Self::API_PATHS)?,
337            timeout: *crate::rpc::DEFAULT_REQUEST_TIMEOUT,
338        })
339    }
340    fn call_raw(
341        client: &crate::rpc::client::Client,
342        params: Self::Params,
343    ) -> impl Future<Output = Result<<Self::Ok as HasLotusJson>::LotusJson, jsonrpsee::core::ClientError>>
344    {
345        async {
346            // TODO(forest): https://github.com/ChainSafe/forest/issues/4032
347            //               Client::call has an inappropriate HasLotusJson
348            //               bound, work around it for now.
349            let json = client.call(Self::request(params)?.map_ty()).await?;
350            Ok(crate::rpc::json_validator::from_value_rejecting_unknown_fields(json)?)
351        }
352    }
353    fn call(
354        client: &crate::rpc::client::Client,
355        params: Self::Params,
356    ) -> impl Future<Output = Result<Self::Ok, jsonrpsee::core::ClientError>> {
357        async {
358            Self::call_raw(client, params)
359                .await
360                .map(Self::Ok::from_lotus_json)
361        }
362    }
363
364    fn api_path(ext: &http::Extensions) -> anyhow::Result<ApiPaths> {
365        ext.get::<ApiPaths>()
366            .copied()
367            .context("failed to resolve api path")
368    }
369}
370impl<const ARITY: usize, T> RpcMethodExt<ARITY> for T where T: RpcMethod<ARITY> {}
371
372/// A tuple of `ARITY` arguments.
373///
374/// This should NOT be manually implemented.
375pub trait Params<const ARITY: usize>: HasLotusJson {
376    /// A [`Schema`] and [`Optional::optional`](`util::Optional::optional`)
377    /// schema-nullable pair for argument, in-order.
378    fn schemas(g: &mut SchemaGenerator) -> [(Schema, bool); ARITY];
379    /// Convert from raw request parameters, to the argument tuple required by
380    /// [`RpcMethod::handle`]
381    fn parse(
382        raw: Option<RequestParameters>,
383        names: [&str; ARITY],
384        calling_convention: ParamStructure,
385        n_required: usize,
386    ) -> Result<Self, Error>
387    where
388        Self: Sized;
389    /// Convert from an argument tuple to un-typed JSON.
390    ///
391    /// Exposes de-serialization errors, or mis-implementation of this trait.
392    fn unparse(self) -> Result<[serde_json::Value; ARITY], serde_json::Error> {
393        match self.into_lotus_json_value() {
394            Ok(serde_json::Value::Array(args)) => match args.try_into() {
395                Ok(it) => Ok(it),
396                Err(_) => Err(serde_json::Error::custom("ARITY mismatch")),
397            },
398            Ok(serde_json::Value::Null) if ARITY == 0 => {
399                Ok(std::array::from_fn(|_ix| Default::default()))
400            }
401            Ok(it) => Err(serde_json::Error::invalid_type(
402                unexpected(&it),
403                &"a Vec with an item for each argument",
404            )),
405            Err(e) => Err(e),
406        }
407    }
408}
409
410fn unexpected(v: &serde_json::Value) -> Unexpected<'_> {
411    match v {
412        serde_json::Value::Null => Unexpected::Unit,
413        serde_json::Value::Bool(it) => Unexpected::Bool(*it),
414        serde_json::Value::Number(it) => match (it.as_f64(), it.as_i64(), it.as_u64()) {
415            (None, None, None) => Unexpected::Other("Number"),
416            (Some(it), _, _) => Unexpected::Float(it),
417            (_, Some(it), _) => Unexpected::Signed(it),
418            (_, _, Some(it)) => Unexpected::Unsigned(it),
419        },
420        serde_json::Value::String(it) => Unexpected::Str(it),
421        serde_json::Value::Array(_) => Unexpected::Seq,
422        serde_json::Value::Object(_) => Unexpected::Map,
423    }
424}
425
426macro_rules! do_impls {
427    ($arity:literal $(, $arg:ident)* $(,)?) => {
428        const _: () = {
429            let _assert: [&str; $arity] = [$(stringify!($arg)),*];
430        };
431
432        impl<$($arg),*> Params<$arity> for ($($arg,)*)
433        where
434            $($arg: HasLotusJson + Clone, <$arg as HasLotusJson>::LotusJson: JsonSchema, )*
435        {
436            fn parse(
437                raw: Option<RequestParameters>,
438                arg_names: [&str; $arity],
439                calling_convention: ParamStructure,
440                n_required: usize,
441            ) -> Result<Self, Error> {
442                let mut _parser = Parser::new(raw, &arg_names, calling_convention, n_required)?;
443                Ok(($(_parser.parse::<crate::lotus_json::LotusJson<$arg>>()?.into_inner(),)*))
444            }
445            fn schemas(_gen: &mut SchemaGenerator) -> [(Schema, bool); $arity] {
446                [$((_gen.subschema_for::<$arg::LotusJson>(), $arg::LotusJson::optional())),*]
447            }
448        }
449    };
450}
451
452do_impls!(0);
453do_impls!(1, T0);
454do_impls!(2, T0, T1);
455do_impls!(3, T0, T1, T2);
456do_impls!(4, T0, T1, T2, T3);
457// do_impls!(5, T0, T1, T2, T3, T4);
458// do_impls!(6, T0, T1, T2, T3, T4, T5);
459// do_impls!(7, T0, T1, T2, T3, T4, T5, T6);
460// do_impls!(8, T0, T1, T2, T3, T4, T5, T6, T7);
461// do_impls!(9, T0, T1, T2, T3, T4, T5, T6, T7, T8);
462// do_impls!(10, T0, T1, T2, T3, T4, T5, T6, T7, T8, T9);
463
464/// [`openrpc_types::ParamStructure`] describes accepted param format.
465/// This is an actual param format, used to decide how to construct arguments.
466pub enum ConcreteCallingConvention {
467    ByPosition,
468    #[allow(unused)] // included for completeness
469    ByName,
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn test_api_paths_from_uri() {
478        let v0 = ApiPaths::from_uri(&"http://127.0.0.1:2345/rpc/v0".parse().unwrap()).unwrap();
479        assert_eq!(v0, ApiPaths::V0);
480        let v1 = ApiPaths::from_uri(&"http://127.0.0.1:2345/rpc/v1".parse().unwrap()).unwrap();
481        assert_eq!(v1, ApiPaths::V1);
482        let v2 = ApiPaths::from_uri(&"http://127.0.0.1:2345/rpc/v2".parse().unwrap()).unwrap();
483        assert_eq!(v2, ApiPaths::V2);
484
485        ApiPaths::from_uri(&"http://127.0.0.1:2345/rpc/v3".parse().unwrap()).unwrap_err();
486    }
487}