Skip to main content

poolster_core/
input.rs

1//! Extensible input providers publish native typed contracts for output consumers.
2use std::any::{Any, TypeId};
3use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, ensure};
7use serde::{Deserialize, Serialize};
8
9use crate::engine::Contract;
10
11#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12pub struct InputOperation {
13    pub name: String,
14    pub kind: String,
15}
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub struct InputSummary {
19    /// Extensible format identifier: community formats require no core enum change.
20    pub format: String,
21    pub title: String,
22    pub version: Option<String>,
23    pub types: Vec<String>,
24    pub operations: Vec<InputOperation>,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub struct InputDiagnostic {
29    pub code: String,
30    pub message: String,
31}
32
33/// Native payloads use the same `Contract` trait as generator providers.
34/// A parser may publish several contracts, including an `AdaptedApi` when its
35/// semantics fit Poolster's HTTP model. Reading an absent capability fails explicitly.
36pub struct InputContract {
37    pub summary: InputSummary,
38    pub diagnostics: Vec<InputDiagnostic>,
39    contracts: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
40}
41
42impl InputContract {
43    pub fn new(summary: InputSummary) -> Self {
44        Self {
45            summary,
46            diagnostics: Vec::new(),
47            contracts: HashMap::new(),
48        }
49    }
50
51    pub fn publish<C: Contract>(&mut self, contract: C) -> Result<()> {
52        ensure!(
53            !self.contracts.contains_key(&TypeId::of::<C>()),
54            "input contract {} was already published",
55            C::NAME
56        );
57        self.contracts.insert(TypeId::of::<C>(), Box::new(contract));
58        Ok(())
59    }
60
61    pub fn take<C: Contract>(&mut self) -> Result<C> {
62        self.contracts
63            .remove(&TypeId::of::<C>())
64            .and_then(|contract| contract.downcast::<C>().ok())
65            .map(|contract| *contract)
66            .with_context(|| {
67                format!(
68                    "input provider did not publish required contract {}",
69                    C::NAME
70                )
71            })
72    }
73
74    pub fn get<C: Contract>(&self) -> Result<&C> {
75        self.contracts
76            .get(&TypeId::of::<C>())
77            .and_then(|contract| contract.downcast_ref())
78            .with_context(|| {
79                format!(
80                    "input provider did not publish required contract {}",
81                    C::NAME
82                )
83            })
84    }
85}
86
87/// Provider-neutral source resolution options. Providers reject unsupported options.
88#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
89#[serde(default, deny_unknown_fields)]
90pub struct InputOptions {
91    pub operation_files: Vec<PathBuf>,
92    pub import_roots: Vec<PathBuf>,
93    pub broker: Option<serde_json::Value>,
94    pub workflow_sources: BTreeMap<String, PathBuf>,
95}
96
97pub trait InputPlugin: Send + Sync {
98    /// Unique provider identity, e.g. `graphql.apollo` or `openapi.roas`.
99    fn id(&self) -> &str;
100    /// Protocol/description format this plugin reads.
101    fn format(&self) -> &str;
102    fn load(&self, path: &Path) -> Result<InputContract>;
103    fn load_with_options(&self, path: &Path, options: &InputOptions) -> Result<InputContract> {
104        ensure!(
105            options == &InputOptions::default(),
106            "input provider {} does not support these options",
107            self.id()
108        );
109        self.load(path)
110    }
111}
112
113#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
114pub struct InputPluginInfo {
115    pub provider: String,
116    pub format: String,
117}
118
119pub struct LoadedInput {
120    pub provider: String,
121    pub source: PathBuf,
122    pub contract: InputContract,
123}
124
125#[derive(Default)]
126pub struct InputRegistry {
127    plugins: BTreeMap<String, Box<dyn InputPlugin>>,
128}
129
130fn valid_identifier(value: &str) -> bool {
131    value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
132        && value.bytes().all(|byte| {
133            byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
134        })
135}
136
137impl InputRegistry {
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    pub fn register<P: InputPlugin + 'static>(&mut self, plugin: P) -> Result<()> {
143        ensure!(
144            valid_identifier(plugin.id()),
145            "invalid input provider identifier {:?}",
146            plugin.id()
147        );
148        ensure!(
149            valid_identifier(plugin.format()),
150            "invalid input format identifier {:?}",
151            plugin.format()
152        );
153        ensure!(
154            !self.plugins.contains_key(plugin.id()),
155            "duplicate input provider {:?}",
156            plugin.id()
157        );
158        self.plugins
159            .insert(plugin.id().to_owned(), Box::new(plugin));
160        Ok(())
161    }
162
163    pub fn plugins(&self) -> Vec<InputPluginInfo> {
164        self.plugins
165            .values()
166            .map(|plugin| InputPluginInfo {
167                provider: plugin.id().to_owned(),
168                format: plugin.format().to_owned(),
169            })
170            .collect()
171    }
172
173    /// Several providers may serve the same format; callers select the provider
174    /// explicitly when automatic selection would be ambiguous.
175    pub fn load(&self, format: &str, provider: Option<&str>, path: &Path) -> Result<LoadedInput> {
176        self.load_with_options(format, provider, path, &InputOptions::default())
177    }
178    pub fn load_with_options(
179        &self,
180        format: &str,
181        provider: Option<&str>,
182        path: &Path,
183        options: &InputOptions,
184    ) -> Result<LoadedInput> {
185        let plugin = if let Some(id) = provider {
186            let plugin = self
187                .plugins
188                .get(id)
189                .with_context(|| format!("unknown input provider {id:?}"))?;
190            ensure!(
191                plugin.format() == format,
192                "input provider {id:?} reads {}, not {format}",
193                plugin.format()
194            );
195            plugin.as_ref()
196        } else {
197            let matching: Vec<_> = self
198                .plugins
199                .values()
200                .filter(|plugin| plugin.format() == format)
201                .collect();
202            ensure!(
203                !matching.is_empty(),
204                "no input provider registered for format {format:?}"
205            );
206            ensure!(
207                matching.len() == 1,
208                "multiple input providers for {format:?}: {}; select --provider explicitly",
209                matching
210                    .iter()
211                    .map(|plugin| plugin.id())
212                    .collect::<Vec<_>>()
213                    .join(", ")
214            );
215            matching[0].as_ref()
216        };
217        let contract = plugin.load_with_options(path, options).with_context(|| {
218            format!(
219                "input provider {} failed reading {}",
220                plugin.id(),
221                path.display()
222            )
223        })?;
224        ensure!(
225            contract.summary.format == format,
226            "input provider {} published format {:?}, expected {format:?}",
227            plugin.id(),
228            contract.summary.format
229        );
230        Ok(LoadedInput {
231            provider: plugin.id().to_owned(),
232            source: path.to_path_buf(),
233            contract,
234        })
235    }
236}
237
238impl Contract for crate::AdaptedApi {
239    const NAME: &'static str = "poolster.http-api";
240}
241
242/// Bridges a selected input contract into the ordinary provider/consumer graph.
243/// Consumers declare `Requirement::on(provider.handle())` as for any renderer.
244pub struct InputProvider<C: Contract> {
245    registry: std::sync::Arc<InputRegistry>,
246    format: String,
247    provider: Option<String>,
248    source: PathBuf,
249    options: InputOptions,
250    meta: crate::engine::Meta,
251    marker: std::marker::PhantomData<fn() -> C>,
252}
253
254impl<C: Contract> InputProvider<C> {
255    pub fn new(
256        registry: std::sync::Arc<InputRegistry>,
257        format: impl Into<String>,
258        source: impl Into<PathBuf>,
259    ) -> Self {
260        Self {
261            registry,
262            format: format.into(),
263            source: source.into(),
264            provider: None,
265            options: InputOptions::default(),
266            meta: crate::engine::Meta::new(),
267            marker: std::marker::PhantomData,
268        }
269    }
270    pub fn using(mut self, provider: impl Into<String>) -> Self {
271        self.provider = Some(provider.into());
272        self
273    }
274    pub fn with_options(mut self, options: InputOptions) -> Self {
275        self.options = options;
276        self
277    }
278    pub fn handle(&self) -> crate::engine::Handle<C> {
279        self.meta.handle()
280    }
281}
282
283impl<C: Contract, L: crate::engine::Language> crate::engine::Plugin<L> for InputProvider<C> {
284    fn supports_native_input(&self) -> bool {
285        true
286    }
287    fn kind(&self) -> &'static str {
288        "input-provider"
289    }
290    fn meta(&self) -> &crate::engine::Meta {
291        &self.meta
292    }
293    fn provides(&self) -> Vec<crate::engine::Provision> {
294        vec![crate::engine::Provision::of::<C>()]
295    }
296    fn generate(&self, cx: &mut crate::engine::PluginContext<'_, L>) -> Result<()> {
297        let mut input = self
298            .registry
299            .load_with_options(
300                &self.format,
301                self.provider.as_deref(),
302                &self.source,
303                &self.options,
304            )?
305            .contract;
306        cx.publish(input.take::<C>()?)
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    struct Native(String);
314    impl Contract for Native {
315        const NAME: &'static str = "example.native";
316    }
317    #[derive(Debug)]
318    struct Missing;
319    impl Contract for Missing {
320        const NAME: &'static str = "example.missing";
321    }
322    struct Provider(&'static str);
323    impl InputPlugin for Provider {
324        fn id(&self) -> &str {
325            self.0
326        }
327        fn format(&self) -> &str {
328            "custom"
329        }
330        fn load(&self, path: &Path) -> Result<InputContract> {
331            let mut input = InputContract::new(InputSummary {
332                format: "custom".into(),
333                title: "Custom".into(),
334                version: None,
335                types: vec![],
336                operations: vec![],
337            });
338            input.publish(Native(std::fs::read_to_string(path)?))?;
339            Ok(input)
340        }
341    }
342
343    #[test]
344    fn community_provider_publishes_native_contract_without_core_enum_changes() {
345        let file = tempfile::NamedTempFile::new().unwrap();
346        std::fs::write(file.path(), "native contract").unwrap();
347        let mut registry = InputRegistry::new();
348        registry.register(Provider("custom.parser")).unwrap();
349        let input = registry.load("custom", None, file.path()).unwrap();
350        assert_eq!(input.contract.get::<Native>().unwrap().0, "native contract");
351        assert_eq!(input.provider, "custom.parser");
352        assert!(
353            input
354                .contract
355                .get::<Missing>()
356                .unwrap_err()
357                .to_string()
358                .contains("example.missing")
359        );
360    }
361
362    #[test]
363    fn replacements_need_explicit_selection_and_duplicate_ids_fail() {
364        let file = tempfile::NamedTempFile::new().unwrap();
365        let mut registry = InputRegistry::new();
366        registry.register(Provider("custom.first")).unwrap();
367        registry.register(Provider("custom.second")).unwrap();
368        assert!(
369            registry
370                .load("custom", None, file.path())
371                .err()
372                .unwrap()
373                .to_string()
374                .contains("multiple input providers")
375        );
376        assert!(
377            registry
378                .load("custom", Some("custom.second"), file.path())
379                .is_ok()
380        );
381        assert!(registry.register(Provider("custom.first")).is_err());
382        assert!(
383            registry
384                .load("other", Some("custom.first"), file.path())
385                .is_err()
386        );
387        assert!(registry.load("other", None, file.path()).is_err());
388        assert!(
389            registry
390                .load("custom", Some("unknown"), file.path())
391                .is_err()
392        );
393    }
394
395    #[test]
396    fn registry_rejects_invalid_ids_and_false_format_claims() {
397        struct Wrong;
398        impl InputPlugin for Wrong {
399            fn id(&self) -> &str {
400                "custom.wrong"
401            }
402            fn format(&self) -> &str {
403                "custom"
404            }
405            fn load(&self, _: &Path) -> Result<InputContract> {
406                Ok(InputContract::new(InputSummary {
407                    format: "other".into(),
408                    title: String::new(),
409                    version: None,
410                    types: vec![],
411                    operations: vec![],
412                }))
413            }
414        }
415        let mut registry = InputRegistry::new();
416        assert!(registry.register(Provider("../invalid")).is_err());
417        registry.register(Wrong).unwrap();
418        assert!(
419            registry
420                .load("custom", None, Path::new("unused"))
421                .err()
422                .unwrap()
423                .to_string()
424                .contains("published format")
425        );
426    }
427
428    #[test]
429    fn duplicate_publications_preserve_first_contract() {
430        let mut input = InputContract::new(InputSummary {
431            format: "custom".into(),
432            title: "Custom".into(),
433            version: None,
434            types: vec![],
435            operations: vec![],
436        });
437        input.publish(Native("first".into())).unwrap();
438        assert!(input.publish(Native("second".into())).is_err());
439        assert_eq!(input.get::<Native>().unwrap().0, "first");
440    }
441}