Skip to main content

apcore_toolkit/output/
registry_writer.rs

1// Registry writer for direct module registration.
2//
3// Converts ScannedModule instances into apcore Module implementations
4// and registers them directly into an apcore Registry.
5//
6// Framework adapters provide a `HandlerFactory` to resolve targets to real
7// async handlers. Without a factory, modules are registered with a passthrough
8// handler that echoes inputs (useful for schema-only registration).
9
10use std::pin::Pin;
11use std::sync::Arc;
12
13use tracing::{debug, warn};
14
15use apcore::context::Context;
16use apcore::errors::ModuleError;
17use apcore::Registry;
18
19use crate::output::types::{Verifier, WriteResult};
20use crate::output::verifiers::{run_verifier_chain, RegistryVerifier};
21use crate::types::ScannedModule;
22
23// TODO(release-gate): deep-chain parity with Python/TypeScript RegistryWriter — manual
24// review required. RegistryWriter is the primary candidate for missing-registration bugs
25// (audit D11 was inconclusive). Verify that all three SDKs perform equivalent registry
26// mutations and handle the same error paths before tagging 0.5.0.
27
28/// Async handler function type for registered modules.
29pub type HandlerFn = Arc<
30    dyn for<'a> Fn(
31            serde_json::Value,
32            &'a Context<serde_json::Value>,
33        ) -> Pin<
34            Box<
35                dyn std::future::Future<Output = Result<serde_json::Value, ModuleError>>
36                    + Send
37                    + 'a,
38            >,
39        > + Send
40        + Sync,
41>;
42
43/// Factory that resolves a `target` string to an async handler.
44///
45/// Framework adapters implement this to map target strings (e.g., `"myapp:get_user"`)
46/// to actual handler functions. For example, an Axum adapter might look up the
47/// handler in a route table; a generic adapter might use a dynamic dispatch map.
48///
49/// ```ignore
50/// let factory: HandlerFactory = Arc::new(|target: &str| {
51///     let handler = lookup_handler(target);
52///     Some(Arc::new(move |inputs, _ctx| {
53///         let h = handler.clone();
54///         Box::pin(async move { h.call(inputs).await })
55///     }))
56/// });
57/// let writer = RegistryWriter::with_handler_factory(factory);
58/// ```
59pub type HandlerFactory = Arc<dyn Fn(&str) -> Option<HandlerFn> + Send + Sync>;
60
61/// Registers ScannedModule instances directly into an apcore Registry.
62///
63/// This is the default writer used when no output_format is specified.
64/// Instead of writing files, it registers modules directly for immediate use.
65///
66/// ## Handler Resolution
67///
68/// By default (`RegistryWriter::new()`), modules are registered with a passthrough
69/// handler that returns inputs unchanged — useful for schema-only registration
70/// where execution is handled elsewhere.
71///
72/// For executable modules, use `RegistryWriter::with_handler_factory(factory)` to
73/// provide a [`HandlerFactory`] that resolves target strings to real handlers.
74pub struct RegistryWriter {
75    handler_factory: Option<HandlerFactory>,
76    /// Optional allow-list of `target` prefixes. When set, any module whose
77    /// `target` does not start with one of these prefixes is rejected with a
78    /// failed `WriteResult` before any handler factory is invoked. Mirrors the
79    /// `allowed_prefixes` parameter on the Python and TypeScript SDKs and
80    /// provides a defence-in-depth boundary on dynamically-supplied targets.
81    allowed_prefixes: Option<Vec<String>>,
82}
83
84impl Default for RegistryWriter {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl RegistryWriter {
91    /// Create a RegistryWriter with passthrough handlers (schema-only registration).
92    ///
93    /// # Handler resolution
94    ///
95    /// Unlike the Python and TypeScript implementations which dynamically import
96    /// the target function at write time (`resolve_target`), the Rust implementation
97    /// registers a passthrough handler that echoes its inputs when no HandlerFactory
98    /// is configured. This means calling a module registered by this writer will
99    /// succeed but will not execute real business logic. To register real handlers,
100    /// use the HandlerFactory integration.
101    ///
102    /// # Panics
103    ///
104    /// This constructor does not panic. However, note that without a `HandlerFactory`,
105    /// all registered modules will use a passthrough handler that echoes inputs unchanged.
106    /// This is suitable for schema-only registration. For real execution, use
107    /// [`RegistryWriter::with_handler_factory`] to supply a factory that resolves targets
108    /// to actual async handlers.
109    pub fn new() -> Self {
110        Self {
111            handler_factory: None,
112            allowed_prefixes: None,
113        }
114    }
115
116    /// Create a RegistryWriter with a custom handler factory for target resolution.
117    pub fn with_handler_factory(factory: HandlerFactory) -> Self {
118        Self {
119            handler_factory: Some(factory),
120            allowed_prefixes: None,
121        }
122    }
123
124    /// Restrict registration to modules whose `target` starts with one of the
125    /// supplied prefixes. Modules with a non-matching target are rejected with
126    /// a failed `WriteResult` and never reach the handler factory.
127    ///
128    /// Matches the `allowed_prefixes` parameter on the Python `RegistryWriter`
129    /// and the TypeScript `allowedPrefixes` option. Use it to bound the set of
130    /// callable Python/Rust paths a binding YAML may resolve to (defence in
131    /// depth against forged or attacker-controlled `target` strings).
132    pub fn with_allowed_prefixes(mut self, prefixes: Vec<String>) -> Self {
133        self.allowed_prefixes = Some(prefixes);
134        self
135    }
136
137    /// Returns `true` when the module target is permitted by the configured
138    /// `allowed_prefixes` (or when no allow-list is configured).
139    fn target_allowed(&self, target: &str) -> bool {
140        match self.allowed_prefixes.as_ref() {
141            None => true,
142            Some(prefixes) => prefixes.iter().any(|p| target.starts_with(p.as_str())),
143        }
144    }
145}
146
147impl RegistryWriter {
148    /// Register scanned modules into the registry.
149    ///
150    /// - `registry`: The apcore Registry to register modules into.
151    /// - `dry_run`: If true, skip registration and return results only.
152    /// - `verify`: If true, verify modules are retrievable after registration.
153    /// - `verifiers`: Optional custom verifiers run after the built-in check.
154    ///
155    /// # Verifier contract for registry-based modules
156    ///
157    /// Registry modules have no output file, so custom verifiers receive
158    /// `path = ""`. Built-in file-based verifiers (`YAMLVerifier`, `JSONVerifier`,
159    /// etc.) skip gracefully when path is empty. Custom verifiers must also
160    /// handle `path = ""` without erroring — use `module_id` for any
161    /// registry-based checks.
162    pub fn write(
163        &self,
164        modules: &[ScannedModule],
165        registry: &mut Registry,
166        dry_run: bool,
167        verify: bool,
168        verifiers: Option<&[&dyn Verifier]>,
169    ) -> Vec<WriteResult> {
170        let mut results: Vec<WriteResult> = Vec::new();
171
172        for module in modules {
173            if dry_run {
174                results.push(WriteResult::new(module.module_id.clone()));
175                continue;
176            }
177
178            if !self.target_allowed(&module.target) {
179                warn!(
180                    module_id = %module.module_id,
181                    target = %module.target,
182                    "RegistryWriter: target rejected by allowed_prefixes"
183                );
184                results.push(WriteResult::failed(
185                    module.module_id.clone(),
186                    None,
187                    format!(
188                        "target '{}' is not in allowed_prefixes — registration refused",
189                        module.target
190                    ),
191                ));
192                continue;
193            }
194
195            let fm = self.to_function_module(module);
196            // Register with a descriptor
197            let descriptor = apcore::registry::registry::ModuleDescriptor {
198                module_id: module.module_id.clone(),
199                name: Some(module.module_id.clone()),
200                description: module.description.clone(),
201                documentation: module.documentation.clone(),
202                input_schema: module.input_schema.clone(),
203                output_schema: module.output_schema.clone(),
204                version: module.version.clone(),
205                tags: module.tags.clone(),
206                annotations: module.annotations.clone(),
207                examples: module.examples.clone(),
208                metadata: module.metadata.clone(),
209                display: module.display.clone(),
210                sunset_date: None,
211                dependencies: vec![],
212                enabled: true,
213            };
214            // Note: unlike Python/TypeScript, Rust collects per-module registration errors
215            // rather than aborting. This is intentional — partial registration is preferred
216            // over a hard stop, giving callers the opportunity to inspect and handle each failure.
217            if let Err(e) = registry.register(&module.module_id, Box::new(fm), descriptor) {
218                warn!(
219                    module_id = %module.module_id,
220                    error = %e,
221                    "RegistryWriter registration failed"
222                );
223                results.push(WriteResult::failed(
224                    module.module_id.clone(),
225                    None,
226                    format!("Registration failed: {e}"),
227                ));
228                continue;
229            }
230            debug!("Registered module: {}", module.module_id);
231
232            let mut result = WriteResult::new(module.module_id.clone());
233            if verify {
234                result = verify_registry(&result, &module.module_id, registry);
235            }
236            if result.verified {
237                if let Some(vs) = verifiers {
238                    let chain_result = run_verifier_chain(vs, "", &module.module_id);
239                    if !chain_result.ok {
240                        result = WriteResult::failed(
241                            result.module_id,
242                            result.path,
243                            chain_result.error.unwrap_or_default(),
244                        );
245                    }
246                }
247            }
248            results.push(result);
249        }
250
251        results
252    }
253}
254
255impl RegistryWriter {
256    /// Convert a ScannedModule to an apcore FunctionModule.
257    ///
258    /// If a handler factory is configured and resolves the target, uses the
259    /// resolved handler. Otherwise falls back to a passthrough handler that
260    /// returns inputs unchanged.
261    fn to_function_module(&self, module: &ScannedModule) -> apcore::decorator::FunctionModule {
262        let annotations = module.annotations.clone().unwrap_or_default();
263        let input_schema = module.input_schema.clone();
264        let output_schema = module.output_schema.clone();
265
266        // Try to resolve the target via the handler factory
267        if let Some(factory) = &self.handler_factory {
268            if let Some(handler) = factory(&module.target) {
269                return apcore::decorator::FunctionModule::new::<_, ()>(
270                    annotations,
271                    input_schema,
272                    output_schema,
273                    move |inputs: serde_json::Value,
274                          ctx: &Context<serde_json::Value>|
275                          -> Pin<
276                        Box<
277                            dyn std::future::Future<Output = Result<serde_json::Value, ModuleError>>
278                                + Send
279                                + '_,
280                        >,
281                    > { handler(inputs, ctx) },
282                );
283            }
284        }
285
286        // Fallback: passthrough handler (schema-only registration)
287        debug!(
288            module_id = %module.module_id,
289            "RegistryWriter using passthrough handler (no HandlerFactory configured)",
290        );
291        fn passthrough<'a>(
292            inputs: serde_json::Value,
293            _ctx: &'a Context<serde_json::Value>,
294        ) -> Pin<
295            Box<
296                dyn std::future::Future<Output = Result<serde_json::Value, ModuleError>>
297                    + Send
298                    + 'a,
299            >,
300        > {
301            Box::pin(async move { Ok(inputs) })
302        }
303
304        apcore::decorator::FunctionModule::new::<_, ()>(
305            annotations,
306            input_schema,
307            output_schema,
308            passthrough,
309        )
310    }
311}
312
313/// Verify that a module was successfully registered and is retrievable.
314fn verify_registry(result: &WriteResult, module_id: &str, registry: &Registry) -> WriteResult {
315    let verifier = RegistryVerifier::new(registry);
316    let vr = verifier.verify("", module_id);
317    if vr.ok {
318        result.clone()
319    } else {
320        WriteResult::failed(module_id.into(), None, vr.error.unwrap_or_default())
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use serde_json::json;
328
329    fn sample_module() -> ScannedModule {
330        ScannedModule::new(
331            "users.get".into(),
332            "Get user".into(),
333            json!({"type": "object"}),
334            json!({"type": "object"}),
335            vec!["users".into()],
336            "app:get_user".into(),
337        )
338    }
339
340    #[test]
341    fn test_write_dry_run() {
342        let writer = RegistryWriter::new();
343        let mut registry = Registry::new();
344        let modules = vec![sample_module()];
345        let results = writer.write(&modules, &mut registry, true, false, None);
346        assert_eq!(results.len(), 1);
347        assert_eq!(results[0].module_id, "users.get");
348        assert!(!registry.has("users.get"));
349    }
350
351    #[test]
352    fn test_write_registers_module() {
353        let writer = RegistryWriter::new();
354        let mut registry = Registry::new();
355        let modules = vec![sample_module()];
356        let results = writer.write(&modules, &mut registry, false, false, None);
357        assert_eq!(results.len(), 1);
358        assert!(registry.has("users.get"));
359    }
360
361    #[test]
362    fn test_write_with_verify() {
363        let writer = RegistryWriter::new();
364        let mut registry = Registry::new();
365        let modules = vec![sample_module()];
366        let results = writer.write(&modules, &mut registry, false, true, None);
367        assert_eq!(results.len(), 1);
368        assert!(results[0].verified);
369    }
370
371    #[test]
372    fn test_write_empty_list() {
373        let writer = RegistryWriter::new();
374        let mut registry = Registry::new();
375        let results = writer.write(&[], &mut registry, false, false, None);
376        assert!(results.is_empty());
377    }
378
379    #[test]
380    fn test_custom_verifier_runs_even_when_verify_false() {
381        // D11-011: verify=false skips the built-in registry check, but custom
382        // verifiers must still run. A failing custom verifier with verify=false
383        // should produce a result with verified=false.
384        use crate::output::types::{Verifier, VerifyResult};
385
386        struct AlwaysFail;
387        impl Verifier for AlwaysFail {
388            fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
389                VerifyResult::fail("custom verifier failed".into())
390            }
391        }
392
393        let writer = RegistryWriter::new();
394        let mut registry = Registry::new();
395        let modules = vec![sample_module()];
396        let failing_verifier = AlwaysFail;
397        let verifiers: &[&dyn Verifier] = &[&failing_verifier];
398        // verify=false: built-in registry check skipped, but custom verifier runs
399        let results = writer.write(&modules, &mut registry, false, false, Some(verifiers));
400        assert_eq!(results.len(), 1);
401        // Module was registered successfully
402        assert!(registry.has("users.get"));
403        // But custom verifier ran and failed — verified must be false
404        assert!(
405            !results[0].verified,
406            "custom verifier must run even when verify=false; result: {:?}",
407            results[0]
408        );
409        assert!(
410            results[0]
411                .verification_error
412                .as_deref()
413                .unwrap_or("")
414                .contains("custom verifier failed"),
415            "verification_error should contain the custom verifier message"
416        );
417    }
418
419    #[test]
420    fn test_write_multiple_modules() {
421        let writer = RegistryWriter::new();
422        let mut registry = Registry::new();
423        let modules = vec![
424            ScannedModule::new(
425                "mod.a".into(),
426                "A".into(),
427                json!({"type": "object"}),
428                json!({"type": "object"}),
429                vec![],
430                "app:a".into(),
431            ),
432            ScannedModule::new(
433                "mod.b".into(),
434                "B".into(),
435                json!({"type": "object"}),
436                json!({"type": "object"}),
437                vec![],
438                "app:b".into(),
439            ),
440        ];
441        let results = writer.write(&modules, &mut registry, false, false, None);
442        assert_eq!(results.len(), 2);
443        assert!(registry.has("mod.a"));
444        assert!(registry.has("mod.b"));
445        assert!(results[0].verified);
446        assert!(results[1].verified);
447    }
448
449    // D11-2 regression: allowed_prefixes is a defence-in-depth allow-list on
450    // the `target` field. A module whose target does not match any prefix
451    // must be rejected with a failed WriteResult and never registered.
452    #[test]
453    fn test_allowed_prefixes_rejects_non_matching_target() {
454        let writer =
455            RegistryWriter::new().with_allowed_prefixes(vec!["app:".into(), "myapp:".into()]);
456        let mut registry = Registry::new();
457        let allowed = sample_module(); // target = "app:get_user"
458        let denied = ScannedModule::new(
459            "evil.module".into(),
460            "Forged target".into(),
461            json!({"type": "object"}),
462            json!({"type": "object"}),
463            vec![],
464            "evil:run_attacker_code".into(),
465        );
466        let results = writer.write(&[allowed, denied], &mut registry, false, false, None);
467        assert_eq!(results.len(), 2);
468        // app:get_user is in allowed_prefixes — registered.
469        assert!(registry.has("users.get"));
470        assert!(results[0].verified);
471        // evil:* is not — rejected, NOT registered.
472        assert!(!registry.has("evil.module"));
473        assert!(!results[1].verified);
474        let err = results[1].verification_error.as_deref().unwrap_or("");
475        assert!(
476            err.contains("allowed_prefixes"),
477            "rejection message should mention allowed_prefixes: got {err:?}"
478        );
479    }
480
481    #[test]
482    fn test_allowed_prefixes_default_none_admits_everything() {
483        // Without allowed_prefixes set, target_allowed must return true for
484        // every input — preserves existing behaviour for callers that have
485        // not opted in.
486        let writer = RegistryWriter::new();
487        let mut registry = Registry::new();
488        let module = ScannedModule::new(
489            "any.module".into(),
490            "Any target".into(),
491            json!({"type": "object"}),
492            json!({"type": "object"}),
493            vec![],
494            "anything-goes:func".into(),
495        );
496        let results = writer.write(&[module], &mut registry, false, false, None);
497        assert_eq!(results.len(), 1);
498        assert!(registry.has("any.module"));
499    }
500}