forest/rpc/reflect/
mod.rs

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