homestar_invocation/task/
instruction.rs

1//! An [Instruction] is the smallest unit of work that can be requested from a
2//! UCAN, described via `resource`, `ability`.
3
4use crate::{
5    ipld::{self, DagCbor},
6    pointer::AwaitResult,
7    Error, Pointer, Unit,
8};
9use libipld::{cid::multibase::Base, serde::from_ipld, Ipld};
10use schemars::{
11    gen::SchemaGenerator,
12    schema::{
13        ArrayValidation, InstanceType, Metadata, ObjectValidation, Schema, SchemaObject,
14        SingleOrVec,
15    },
16    JsonSchema,
17};
18use serde::{Deserialize, Serialize};
19use serde_json::json;
20use std::{
21    borrow::Cow,
22    collections::{BTreeMap, BTreeSet},
23    fmt,
24};
25use url::Url;
26
27const RESOURCE_KEY: &str = "rsc";
28const OP_KEY: &str = "op";
29const INPUT_KEY: &str = "input";
30const NNC_KEY: &str = "nnc";
31
32mod ability;
33pub mod input;
34mod nonce;
35pub use ability::*;
36pub use input::{Args, Input, Parse, Parsed};
37pub use nonce::*;
38
39/// Enumerator for `either` an expanded [Instruction] structure or
40/// an [Pointer] (Cid wrapper).
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub enum RunInstruction<'a, T> {
43    /// [Instruction] as an expanded structure.
44    Expanded(Instruction<'a, T>),
45    /// [Instruction] as a pointer.
46    Ptr(Pointer),
47}
48
49impl<'a, T> From<Instruction<'a, T>> for RunInstruction<'a, T> {
50    fn from(instruction: Instruction<'a, T>) -> Self {
51        RunInstruction::Expanded(instruction)
52    }
53}
54
55impl<'a, T> TryFrom<RunInstruction<'a, T>> for Instruction<'a, T>
56where
57    T: fmt::Debug,
58{
59    type Error = Error<RunInstruction<'a, T>>;
60
61    fn try_from(run: RunInstruction<'a, T>) -> Result<Self, Self::Error> {
62        match run {
63            RunInstruction::Expanded(instruction) => Ok(instruction),
64            e => Err(Error::InvalidDiscriminant(e)),
65        }
66    }
67}
68
69impl<T> From<Pointer> for RunInstruction<'_, T> {
70    fn from(ptr: Pointer) -> Self {
71        RunInstruction::Ptr(ptr)
72    }
73}
74
75impl<'a, T> TryFrom<RunInstruction<'a, T>> for Pointer
76where
77    T: fmt::Debug,
78{
79    type Error = Error<RunInstruction<'a, T>>;
80
81    fn try_from(run: RunInstruction<'a, T>) -> Result<Self, Self::Error> {
82        match run {
83            RunInstruction::Ptr(ptr) => Ok(ptr),
84            e => Err(Error::InvalidDiscriminant(e)),
85        }
86    }
87}
88
89impl<'a, 'b, T> TryFrom<&'b RunInstruction<'a, T>> for &'b Pointer
90where
91    T: fmt::Debug,
92{
93    type Error = Error<&'b RunInstruction<'a, T>>;
94
95    fn try_from(run: &'b RunInstruction<'a, T>) -> Result<Self, Self::Error> {
96        match run {
97            RunInstruction::Ptr(ptr) => Ok(ptr),
98            e => Err(Error::InvalidDiscriminant(e)),
99        }
100    }
101}
102
103impl<'a, 'b, T> TryFrom<&'b RunInstruction<'a, T>> for Pointer
104where
105    T: fmt::Debug,
106{
107    type Error = Error<&'b RunInstruction<'a, T>>;
108
109    fn try_from(run: &'b RunInstruction<'a, T>) -> Result<Self, Self::Error> {
110        match run {
111            RunInstruction::Ptr(ptr) => Ok(ptr.to_owned()),
112            e => Err(Error::InvalidDiscriminant(e)),
113        }
114    }
115}
116
117impl<T> From<RunInstruction<'_, T>> for Ipld
118where
119    Ipld: From<T>,
120{
121    fn from(run: RunInstruction<'_, T>) -> Self {
122        match run {
123            RunInstruction::Expanded(instruction) => instruction.into(),
124            RunInstruction::Ptr(instruction_ptr) => instruction_ptr.into(),
125        }
126    }
127}
128
129impl<T> TryFrom<Ipld> for RunInstruction<'_, T>
130where
131    T: From<Ipld>,
132{
133    type Error = Error<Unit>;
134
135    fn try_from<'a>(ipld: Ipld) -> Result<Self, Self::Error> {
136        match ipld {
137            Ipld::Map(_) => Ok(RunInstruction::Expanded(Instruction::try_from(ipld)?)),
138            Ipld::Link(_) => Ok(RunInstruction::Ptr(Pointer::try_from(ipld)?)),
139            other_ipld => Err(Error::unexpected_ipld(other_ipld)),
140        }
141    }
142}
143
144/// Instruction to be executed.
145///
146/// # Example
147///
148/// ```
149/// use homestar_invocation::{
150///     task::{
151///         instruction::{Ability, Input},
152///         Instruction,
153///     },
154///     Unit,
155///  };
156/// use libipld::Ipld;
157/// use url::Url;
158///
159/// let wasm = "bafybeia32q3oy6u47x624rmsmgrrlpn7ulruissmz5z2ap6alv7goe7h3q".to_string();
160/// let resource = Url::parse(format!("ipfs://{wasm}").as_str()).unwrap();
161///
162/// let instr = Instruction::unique(
163///     resource,
164///     Ability::from("wasm/run"),
165///     Input::<Unit>::Ipld(Ipld::List(vec![Ipld::Bool(true)]))
166/// );
167/// ```
168///
169/// We can also set-up an [Instruction] with a Deferred input to await on:
170/// ```
171/// use homestar_invocation::{
172///     pointer::{Await, AwaitResult},
173///     task::{
174///         instruction::{Ability, Input, Nonce},
175///         Instruction,
176///     },
177///     Pointer, Unit,
178///  };
179/// use libipld::{cid::{multihash::{Code, MultihashDigest}, Cid}, Ipld, Link};
180/// use url::Url;
181
182/// let wasm = "bafybeia32q3oy6u47x624rmsmgrrlpn7ulruissmz5z2ap6alv7goe7h3q".to_string();
183/// let resource = Url::parse(format!("ipfs://{wasm}").as_str()).expect("IPFS URL");
184/// let h = Code::Blake3_256.digest(b"beep boop");
185/// let cid = Cid::new_v1(0x55, h);
186/// let link: Link<Cid> = Link::new(cid);
187/// let awaited_instr = Pointer::new_from_link(link);
188///
189/// let instr = Instruction::new_with_nonce(
190///     resource,
191///     Ability::from("wasm/run"),
192///     Input::<Unit>::Deferred(Await::new(awaited_instr, AwaitResult::Ok)),
193///     Nonce::generate()
194/// );
195///
196/// // And covert it to a pointer:
197/// let ptr = Pointer::try_from(instr).unwrap();
198/// ```
199/// [deferred promise]: super::pointer::Await
200#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
201pub struct Instruction<'a, T> {
202    rsc: Url,
203    op: Cow<'a, Ability>,
204    input: Input<T>,
205    nnc: Nonce,
206}
207
208impl<T> Instruction<'_, T> {
209    /// Create a new [Instruction] with an empty Nonce.
210    pub fn new(rsc: Url, ability: Ability, input: Input<T>) -> Self {
211        Self {
212            rsc,
213            op: Cow::from(ability),
214            input,
215            nnc: Nonce::Empty,
216        }
217    }
218
219    /// Create a new [Instruction] with a given [Nonce].
220    pub fn new_with_nonce(rsc: Url, ability: Ability, input: Input<T>, nnc: Nonce) -> Self {
221        Self {
222            rsc,
223            op: Cow::from(ability),
224            input,
225            nnc,
226        }
227    }
228
229    /// Create a unique [Instruction], with a default [Nonce] generator.
230    pub fn unique(rsc: Url, ability: Ability, input: Input<T>) -> Self {
231        Self {
232            rsc,
233            op: Cow::from(ability),
234            input,
235            nnc: Nonce::generate(),
236        }
237    }
238
239    /// Return [Instruction] resource, i.e. [Url].
240    pub fn resource(&self) -> &Url {
241        &self.rsc
242    }
243
244    /// Return [Ability] associated with `op`.
245    pub fn op(&self) -> &Ability {
246        &self.op
247    }
248
249    /// Return [Instruction] [Input].
250    pub fn input(&self) -> &Input<T> {
251        &self.input
252    }
253
254    /// Return [Nonce] reference.
255    pub fn nonce(&self) -> &Nonce {
256        &self.nnc
257    }
258}
259
260impl<T> TryFrom<Instruction<'_, T>> for Pointer
261where
262    Ipld: From<T>,
263{
264    type Error = Error<Unit>;
265
266    fn try_from(instruction: Instruction<'_, T>) -> Result<Self, Self::Error> {
267        Ok(Pointer::new(instruction.to_cid()?))
268    }
269}
270
271impl<T> From<Instruction<'_, T>> for Ipld
272where
273    Ipld: From<T>,
274{
275    fn from(instruction: Instruction<'_, T>) -> Self {
276        Ipld::Map(BTreeMap::from([
277            (RESOURCE_KEY.into(), instruction.rsc.to_string().into()),
278            (OP_KEY.into(), instruction.op.to_string().into()),
279            (INPUT_KEY.into(), instruction.input.into()),
280            (NNC_KEY.into(), instruction.nnc.into()),
281        ]))
282    }
283}
284
285impl<T> TryFrom<&Ipld> for Instruction<'_, T>
286where
287    T: From<Ipld>,
288{
289    type Error = Error<Unit>;
290
291    fn try_from(ipld: &Ipld) -> Result<Self, Self::Error> {
292        TryFrom::try_from(ipld.to_owned())
293    }
294}
295
296impl<T> TryFrom<Ipld> for Instruction<'_, T>
297where
298    T: From<Ipld>,
299{
300    type Error = Error<Unit>;
301
302    fn try_from(ipld: Ipld) -> Result<Self, Self::Error> {
303        let map = from_ipld::<BTreeMap<String, Ipld>>(ipld)?;
304
305        let rsc = match map.get(RESOURCE_KEY) {
306            Some(Ipld::Link(cid)) => cid
307                .to_string_of_base(Base::Base32Lower) // Cid v1
308                .map_err(Error::<Unit>::CidEncode)
309                .and_then(|txt| {
310                    Url::parse(format!("{}{}", "ipfs://", txt).as_str())
311                        .map_err(Error::ParseResource)
312                }),
313            Some(Ipld::String(txt)) => Url::parse(txt.as_str()).map_err(Error::ParseResource),
314            _ => Err(Error::MissingField(RESOURCE_KEY.to_string())),
315        }?;
316
317        Ok(Self {
318            rsc,
319            op: from_ipld(
320                map.get(OP_KEY)
321                    .ok_or_else(|| Error::<Unit>::MissingField(OP_KEY.to_string()))?
322                    .to_owned(),
323            )?,
324            input: Input::try_from(
325                map.get(INPUT_KEY)
326                    .ok_or_else(|| Error::<String>::MissingField(INPUT_KEY.to_string()))?
327                    .to_owned(),
328            )?,
329            nnc: Nonce::try_from(
330                map.get(NNC_KEY)
331                    .unwrap_or(&Ipld::String("".to_string()))
332                    .to_owned(),
333            )?,
334        })
335    }
336}
337
338impl<'a, T> DagCbor for Instruction<'a, T> where Ipld: From<T> {}
339
340impl<'a, T> JsonSchema for Instruction<'a, T> {
341    fn schema_name() -> String {
342        "run".to_owned()
343    }
344
345    fn schema_id() -> Cow<'static, str> {
346        Cow::Borrowed("homestar-invocation::task::Instruction")
347    }
348
349    fn json_schema(gen: &mut SchemaGenerator) -> Schema {
350        struct InputConditional {
351            if_schema: Schema,
352            then_schema: Schema,
353            else_schema: Schema,
354        }
355
356        fn input_conditional(gen: &mut SchemaGenerator) -> InputConditional {
357            let if_schema = SchemaObject {
358                instance_type: None,
359                object: Some(Box::new(ObjectValidation {
360                    properties: BTreeMap::from([(
361                        "op".to_owned(),
362                        Schema::Object(SchemaObject {
363                            instance_type: Some(SingleOrVec::Single(InstanceType::String.into())),
364                            const_value: Some(json!("wasm/run")),
365                            ..Default::default()
366                        }),
367                    )]),
368                    ..Default::default()
369                })),
370                ..Default::default()
371            };
372
373            let func_schema = SchemaObject {
374                instance_type: Some(SingleOrVec::Single(InstanceType::String.into())),
375                metadata: Some(Box::new(Metadata {
376                    description: Some("The function to call on the Wasm resource".to_string()),
377                    ..Default::default()
378                })),
379                ..Default::default()
380            };
381
382            let args_schema = SchemaObject {
383                instance_type: Some(SingleOrVec::Single(InstanceType::Array.into())),
384                metadata: Some(Box::new(Metadata {
385                    description: Some(
386                        "Arguments to the function. May await a result from another task."
387                            .to_string(),
388                    ),
389                    ..Default::default()
390                })),
391                array: Some(Box::new(ArrayValidation {
392                    items: Some(SingleOrVec::Vec(vec![
393                        gen.subschema_for::<ipld::schema::IpldStub>(),
394                        gen.subschema_for::<AwaitResult>(),
395                    ])),
396                    ..Default::default()
397                })),
398                ..Default::default()
399            };
400
401            let input_schema = SchemaObject {
402                instance_type: Some(SingleOrVec::Single(InstanceType::Object.into())),
403                object: Some(Box::new(ObjectValidation {
404                    properties: BTreeMap::from([
405                        ("func".to_string(), Schema::Object(func_schema)),
406                        ("args".to_string(), Schema::Object(args_schema)),
407                    ]),
408                    required: BTreeSet::from(["func".to_string(), "args".to_string()]),
409                    ..Default::default()
410                })),
411                ..Default::default()
412            };
413
414            let then_schema = SchemaObject {
415                instance_type: None,
416                object: Some(Box::new(ObjectValidation {
417                    properties: BTreeMap::from([(
418                        "input".to_string(),
419                        Schema::Object(input_schema),
420                    )]),
421                    ..Default::default()
422                })),
423                ..Default::default()
424            };
425
426            InputConditional {
427                if_schema: Schema::Object(if_schema),
428                then_schema: Schema::Object(then_schema),
429                else_schema: Schema::Bool(false),
430            }
431        }
432
433        let op_schema = SchemaObject {
434            instance_type: Some(SingleOrVec::Single(InstanceType::String.into())),
435            metadata: Some(Box::new(Metadata {
436                description: Some("Function executor".to_string()),
437                ..Default::default()
438            })),
439            enum_values: Some(vec![json!("wasm/run")]),
440            ..Default::default()
441        };
442
443        let mut schema = SchemaObject {
444            instance_type: Some(SingleOrVec::Single(InstanceType::Object.into())),
445            metadata: Some(Box::new(Metadata {
446                title: Some("Run instruction".to_string()),
447                description: Some("An instruction that runs a function from a resource, executor that will run the function, inputs to the executor, and optional nonce".to_string()),
448                ..Default::default()
449            })),
450            object: Some(Box::new(ObjectValidation {
451                properties: BTreeMap::from([
452                    ("rsc".to_owned(), <Url>::json_schema(gen)),
453                    ("op".to_owned(), Schema::Object(op_schema)),
454                    ("nnc".to_owned(), <Nonce>::json_schema(gen))
455                ]),
456                required: BTreeSet::from(["rsc".to_string(), "op".to_string(), "input".to_string(), "nnc".to_string()]),
457                ..Default::default()
458            })),
459            ..Default::default()
460        };
461
462        let input = input_conditional(gen);
463        schema.subschemas().if_schema = Some(Box::new(input.if_schema));
464        schema.subschemas().then_schema = Some(Box::new(input.then_schema));
465        schema.subschemas().else_schema = Some(Box::new(input.else_schema));
466
467        schema.into()
468    }
469}
470
471#[cfg(test)]
472mod test {
473    use super::*;
474    use crate::{test_utils, DAG_CBOR};
475    use libipld::{
476        cbor::DagCborCodec,
477        multihash::{Code, MultihashDigest},
478        prelude::Codec,
479        Cid,
480    };
481
482    #[test]
483    fn ipld_roundtrip() {
484        let (instruction, bytes) = test_utils::instruction_with_nonce::<Unit>();
485        let ipld = Ipld::from(instruction.clone());
486
487        assert_eq!(
488            ipld,
489            Ipld::Map(BTreeMap::from([
490                (
491                    RESOURCE_KEY.into(),
492                    Ipld::String(
493                        "ipfs://bafybeia32q3oy6u47x624rmsmgrrlpn7ulruissmz5z2ap6alv7goe7h3q".into()
494                    )
495                ),
496                (OP_KEY.into(), Ipld::String("ipld/fun".to_string())),
497                (INPUT_KEY.into(), Ipld::List(vec![Ipld::Bool(true)])),
498                (NNC_KEY.into(), Ipld::Bytes(bytes))
499            ]))
500        );
501        assert_eq!(instruction, ipld.try_into().unwrap())
502    }
503
504    #[test]
505    fn ipld_cid_trials() {
506        let a_cid =
507            Cid::try_from("bafyrmiev5j2jzjrqncbfqo6pbraiw7r2p527m4z3bbm6ir3o5kdz2zwcjy").unwrap();
508        let ipld = libipld::ipld!({"input":
509                        {
510                            "args": [{"await/ok": a_cid}, "111111"],
511                            "func": "join-strings"
512                        },
513                        "nnc": "", "op": "wasm/run",
514                        "rsc": "ipfs://bafybeia32q3oy6u47x624rmsmgrrlpn7ulruissmz5z2ap6alv7goe7h3q"});
515
516        let instruction = Instruction::<Unit>::try_from(ipld.clone()).unwrap();
517        let instr_cid = instruction.to_cid().unwrap();
518
519        let bytes = DagCborCodec.encode(&ipld).unwrap();
520        let hash = Code::Sha3_256.digest(&bytes);
521        let ipld_to_cid = Cid::new_v1(DAG_CBOR, hash);
522
523        assert_eq!(ipld_to_cid, instr_cid);
524    }
525
526    #[test]
527    fn ser_de() {
528        let (instruction, _bytes) = test_utils::instruction_with_nonce::<Unit>();
529        let ser = serde_json::to_string(&instruction).unwrap();
530        let de = serde_json::from_str(&ser).unwrap();
531
532        assert_eq!(instruction, de);
533    }
534}