Skip to main content

drasi_bootstrap_http/
descriptor.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Plugin descriptor for the HTTP bootstrap provider.
16
17use std::collections::HashMap;
18
19use drasi_lib::bootstrap::BootstrapProvider;
20use drasi_plugin_sdk::prelude::*;
21use utoipa::OpenApi;
22
23use crate::config::{
24    ApiKeyLocation, AuthConfig, ContentTypeOverride, ElementMappingConfig, ElementTemplate,
25    ElementType, EndpointConfig, HttpBootstrapConfig, HttpMethod, PaginationConfig, ResponseConfig,
26};
27use crate::provider::HttpBootstrapProvider;
28
29// ── DTO types ────────────────────────────────────────────────────────────────
30
31/// Top-level configuration DTO for the HTTP bootstrap provider.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
33#[schema(as = bootstrap::http::HttpBootstrapConfig)]
34#[serde(rename_all = "camelCase", deny_unknown_fields)]
35pub struct HttpBootstrapConfigDto {
36    /// Endpoint configurations.
37    #[schema(value_type = Vec<bootstrap::http::EndpointConfig>)]
38    pub endpoints: Vec<EndpointConfigDto>,
39
40    /// Timeout in seconds.
41    #[serde(default = "default_timeout")]
42    pub timeout_seconds: ConfigValue<u64>,
43
44    /// Maximum retries.
45    #[serde(default = "default_retries")]
46    pub max_retries: ConfigValue<u32>,
47
48    /// Retry delay in milliseconds.
49    #[serde(default = "default_retry_delay")]
50    pub retry_delay_ms: ConfigValue<u64>,
51
52    /// Maximum number of pages to fetch per endpoint (default: 10,000).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub max_pages: Option<ConfigValue<u64>>,
55}
56
57fn default_timeout() -> ConfigValue<u64> {
58    ConfigValue::Static(30)
59}
60
61fn default_retries() -> ConfigValue<u32> {
62    ConfigValue::Static(3)
63}
64
65fn default_retry_delay() -> ConfigValue<u64> {
66    ConfigValue::Static(1000)
67}
68
69/// Configuration DTO for a single HTTP endpoint.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
71#[schema(as = bootstrap::http::EndpointConfig)]
72#[serde(rename_all = "camelCase")]
73pub struct EndpointConfigDto {
74    /// The URL to fetch data from.
75    pub url: ConfigValue<String>,
76
77    /// HTTP method (default: GET).
78    #[serde(default = "default_method")]
79    #[schema(value_type = bootstrap::http::HttpMethod)]
80    pub method: HttpMethodDto,
81
82    /// Additional HTTP headers to include in requests.
83    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
84    pub headers: HashMap<String, ConfigValue<String>>,
85
86    /// Optional request body (for POST/PUT methods).
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub body: Option<serde_json::Value>,
89
90    /// Authentication configuration.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    #[schema(value_type = Option<bootstrap::http::AuthConfig>)]
93    pub auth: Option<AuthConfigDto>,
94
95    /// Pagination configuration.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    #[schema(value_type = Option<bootstrap::http::PaginationConfig>)]
98    pub pagination: Option<PaginationConfigDto>,
99
100    /// Response parsing and element mapping configuration.
101    #[schema(value_type = bootstrap::http::ResponseConfig)]
102    pub response: ResponseConfigDto,
103}
104
105fn default_method() -> HttpMethodDto {
106    HttpMethodDto::Get
107}
108
109/// HTTP methods supported for bootstrap requests.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
111#[schema(as = bootstrap::http::HttpMethod)]
112#[serde(rename_all = "UPPERCASE")]
113pub enum HttpMethodDto {
114    Get,
115    Post,
116    Put,
117}
118
119/// Authentication configuration DTO.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
121#[schema(as = bootstrap::http::AuthConfig)]
122#[serde(tag = "type", rename_all = "kebab-case")]
123pub enum AuthConfigDto {
124    /// Bearer token authentication.
125    Bearer {
126        /// Environment variable containing the token.
127        token_env: ConfigValue<String>,
128    },
129    /// API key authentication (in header or query parameter).
130    ApiKey {
131        /// Where to send the API key.
132        #[schema(value_type = bootstrap::http::ApiKeyLocation)]
133        location: ApiKeyLocationDto,
134        /// Header name or query parameter name.
135        name: ConfigValue<String>,
136        /// Environment variable containing the API key value.
137        value_env: ConfigValue<String>,
138    },
139    /// HTTP Basic authentication.
140    Basic {
141        /// Environment variable containing the username.
142        username_env: ConfigValue<String>,
143        /// Environment variable containing the password.
144        #[serde(default, skip_serializing_if = "Option::is_none")]
145        password_env: Option<ConfigValue<String>>,
146    },
147    /// OAuth2 Client Credentials flow.
148    #[serde(rename = "oauth2-client-credentials")]
149    OAuth2ClientCredentials {
150        /// Token endpoint URL.
151        token_url: ConfigValue<String>,
152        /// Environment variable containing the client ID.
153        client_id_env: ConfigValue<String>,
154        /// Environment variable containing the client secret.
155        client_secret_env: ConfigValue<String>,
156        /// Optional scopes to request.
157        #[serde(default, skip_serializing_if = "Vec::is_empty")]
158        scopes: Vec<ConfigValue<String>>,
159    },
160}
161
162/// Where to place an API key.
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
164#[schema(as = bootstrap::http::ApiKeyLocation)]
165#[serde(rename_all = "kebab-case")]
166pub enum ApiKeyLocationDto {
167    Header,
168    Query,
169}
170
171/// Pagination configuration DTO.
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
173#[schema(as = bootstrap::http::PaginationConfig)]
174#[serde(tag = "type", rename_all = "kebab-case")]
175pub enum PaginationConfigDto {
176    /// Offset/limit pagination.
177    OffsetLimit {
178        #[serde(default = "default_offset_param")]
179        offset_param: ConfigValue<String>,
180        #[serde(default = "default_limit_param")]
181        limit_param: ConfigValue<String>,
182        page_size: ConfigValue<u64>,
183        #[serde(default, skip_serializing_if = "Option::is_none")]
184        total_path: Option<ConfigValue<String>>,
185    },
186    /// Page number pagination.
187    PageNumber {
188        #[serde(default = "default_page_param")]
189        page_param: ConfigValue<String>,
190        #[serde(default = "default_per_page_param")]
191        page_size_param: ConfigValue<String>,
192        page_size: ConfigValue<u64>,
193        #[serde(default, skip_serializing_if = "Option::is_none")]
194        total_pages_path: Option<ConfigValue<String>>,
195    },
196    /// Cursor-based pagination.
197    Cursor {
198        cursor_param: ConfigValue<String>,
199        cursor_path: ConfigValue<String>,
200        #[serde(default, skip_serializing_if = "Option::is_none")]
201        has_more_path: Option<ConfigValue<String>>,
202        #[serde(default, skip_serializing_if = "Option::is_none")]
203        page_size_param: Option<ConfigValue<String>>,
204        #[serde(default, skip_serializing_if = "Option::is_none")]
205        page_size: Option<ConfigValue<u64>>,
206    },
207    /// Link header pagination (RFC 5988).
208    LinkHeader {
209        #[serde(default, skip_serializing_if = "Option::is_none")]
210        page_size_param: Option<ConfigValue<String>>,
211        #[serde(default, skip_serializing_if = "Option::is_none")]
212        page_size: Option<ConfigValue<u64>>,
213    },
214    /// Next URL from response body.
215    NextUrl {
216        next_url_path: ConfigValue<String>,
217        #[serde(default, skip_serializing_if = "Option::is_none")]
218        base_url: Option<ConfigValue<String>>,
219    },
220}
221
222fn default_offset_param() -> ConfigValue<String> {
223    ConfigValue::Static("offset".to_string())
224}
225
226fn default_limit_param() -> ConfigValue<String> {
227    ConfigValue::Static("limit".to_string())
228}
229
230fn default_page_param() -> ConfigValue<String> {
231    ConfigValue::Static("page".to_string())
232}
233
234fn default_per_page_param() -> ConfigValue<String> {
235    ConfigValue::Static("per_page".to_string())
236}
237
238/// Response parsing configuration DTO.
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
240#[schema(as = bootstrap::http::ResponseConfig)]
241#[serde(rename_all = "camelCase")]
242pub struct ResponseConfigDto {
243    /// JSONPath expression to locate the array of items in the response.
244    #[serde(default = "default_items_path")]
245    pub items_path: ConfigValue<String>,
246
247    /// Content type override.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    #[schema(value_type = Option<bootstrap::http::ContentTypeOverride>)]
250    pub content_type: Option<ContentTypeOverrideDto>,
251
252    /// Element mapping configurations.
253    #[schema(value_type = Vec<bootstrap::http::ElementMappingConfig>)]
254    pub mappings: Vec<ElementMappingConfigDto>,
255}
256
257fn default_items_path() -> ConfigValue<String> {
258    ConfigValue::Static("$".to_string())
259}
260
261/// Content type override DTO.
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
263#[schema(as = bootstrap::http::ContentTypeOverride)]
264#[serde(rename_all = "lowercase")]
265pub enum ContentTypeOverrideDto {
266    Json,
267    Xml,
268    Yaml,
269}
270
271/// Element mapping configuration DTO.
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
273#[schema(as = bootstrap::http::ElementMappingConfig)]
274#[serde(rename_all = "camelCase")]
275pub struct ElementMappingConfigDto {
276    /// Type of element to create.
277    #[schema(value_type = bootstrap::http::ElementType)]
278    pub element_type: ElementTypeDto,
279
280    /// Template for element creation.
281    #[schema(value_type = bootstrap::http::ElementTemplate)]
282    pub template: ElementTemplateDto,
283}
284
285/// Element type DTO.
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
287#[schema(as = bootstrap::http::ElementType)]
288#[serde(rename_all = "lowercase")]
289pub enum ElementTypeDto {
290    Node,
291    Relation,
292}
293
294/// Element template DTO using Handlebars expressions.
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
296#[schema(as = bootstrap::http::ElementTemplate)]
297#[serde(rename_all = "camelCase")]
298pub struct ElementTemplateDto {
299    /// Handlebars template for element ID.
300    pub id: ConfigValue<String>,
301
302    /// Handlebars templates for element labels.
303    pub labels: Vec<ConfigValue<String>>,
304
305    /// Properties mapping (each value is a Handlebars template or literal).
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub properties: Option<serde_json::Value>,
308
309    /// Template for relation source node ID (relations only).
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub from: Option<ConfigValue<String>>,
312
313    /// Template for relation target node ID (relations only).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub to: Option<ConfigValue<String>>,
316}
317
318// ── Mapping functions ────────────────────────────────────────────────────────
319
320fn map_http_method(dto: &HttpMethodDto) -> HttpMethod {
321    match dto {
322        HttpMethodDto::Get => HttpMethod::Get,
323        HttpMethodDto::Post => HttpMethod::Post,
324        HttpMethodDto::Put => HttpMethod::Put,
325    }
326}
327
328fn map_api_key_location(dto: &ApiKeyLocationDto) -> ApiKeyLocation {
329    match dto {
330        ApiKeyLocationDto::Header => ApiKeyLocation::Header,
331        ApiKeyLocationDto::Query => ApiKeyLocation::Query,
332    }
333}
334
335fn map_element_type(dto: &ElementTypeDto) -> ElementType {
336    match dto {
337        ElementTypeDto::Node => ElementType::Node,
338        ElementTypeDto::Relation => ElementType::Relation,
339    }
340}
341
342fn map_content_type_override(dto: &ContentTypeOverrideDto) -> ContentTypeOverride {
343    match dto {
344        ContentTypeOverrideDto::Json => ContentTypeOverride::Json,
345        ContentTypeOverrideDto::Xml => ContentTypeOverride::Xml,
346        ContentTypeOverrideDto::Yaml => ContentTypeOverride::Yaml,
347    }
348}
349
350fn map_auth_config(dto: &AuthConfigDto, resolver: &DtoMapper) -> Result<AuthConfig, MappingError> {
351    match dto {
352        AuthConfigDto::Bearer { token_env } => Ok(AuthConfig::Bearer {
353            token_env: resolver.resolve_string(token_env)?,
354        }),
355        AuthConfigDto::ApiKey {
356            location,
357            name,
358            value_env,
359        } => Ok(AuthConfig::ApiKey {
360            location: map_api_key_location(location),
361            name: resolver.resolve_string(name)?,
362            value_env: resolver.resolve_string(value_env)?,
363        }),
364        AuthConfigDto::Basic {
365            username_env,
366            password_env,
367        } => Ok(AuthConfig::Basic {
368            username_env: resolver.resolve_string(username_env)?,
369            password_env: resolver.resolve_optional_string(password_env)?,
370        }),
371        AuthConfigDto::OAuth2ClientCredentials {
372            token_url,
373            client_id_env,
374            client_secret_env,
375            scopes,
376        } => Ok(AuthConfig::OAuth2ClientCredentials {
377            token_url: resolver.resolve_string(token_url)?,
378            client_id_env: resolver.resolve_string(client_id_env)?,
379            client_secret_env: resolver.resolve_string(client_secret_env)?,
380            scopes: resolver.resolve_string_vec(scopes)?,
381        }),
382    }
383}
384
385fn map_pagination_config(
386    dto: &PaginationConfigDto,
387    resolver: &DtoMapper,
388) -> Result<PaginationConfig, MappingError> {
389    match dto {
390        PaginationConfigDto::OffsetLimit {
391            offset_param,
392            limit_param,
393            page_size,
394            total_path,
395        } => Ok(PaginationConfig::OffsetLimit {
396            offset_param: resolver.resolve_string(offset_param)?,
397            limit_param: resolver.resolve_string(limit_param)?,
398            page_size: resolver.resolve_typed(page_size)?,
399            total_path: resolver.resolve_optional_string(total_path)?,
400        }),
401        PaginationConfigDto::PageNumber {
402            page_param,
403            page_size_param,
404            page_size,
405            total_pages_path,
406        } => Ok(PaginationConfig::PageNumber {
407            page_param: resolver.resolve_string(page_param)?,
408            page_size_param: resolver.resolve_string(page_size_param)?,
409            page_size: resolver.resolve_typed(page_size)?,
410            total_pages_path: resolver.resolve_optional_string(total_pages_path)?,
411        }),
412        PaginationConfigDto::Cursor {
413            cursor_param,
414            cursor_path,
415            has_more_path,
416            page_size_param,
417            page_size,
418        } => Ok(PaginationConfig::Cursor {
419            cursor_param: resolver.resolve_string(cursor_param)?,
420            cursor_path: resolver.resolve_string(cursor_path)?,
421            has_more_path: resolver.resolve_optional_string(has_more_path)?,
422            page_size_param: resolver.resolve_optional_string(page_size_param)?,
423            page_size: resolver.resolve_optional(page_size)?,
424        }),
425        PaginationConfigDto::LinkHeader {
426            page_size_param,
427            page_size,
428        } => Ok(PaginationConfig::LinkHeader {
429            page_size_param: resolver.resolve_optional_string(page_size_param)?,
430            page_size: resolver.resolve_optional(page_size)?,
431        }),
432        PaginationConfigDto::NextUrl {
433            next_url_path,
434            base_url,
435        } => Ok(PaginationConfig::NextUrl {
436            next_url_path: resolver.resolve_string(next_url_path)?,
437            base_url: resolver.resolve_optional_string(base_url)?,
438        }),
439    }
440}
441
442fn map_element_template(
443    dto: &ElementTemplateDto,
444    resolver: &DtoMapper,
445) -> Result<ElementTemplate, MappingError> {
446    Ok(ElementTemplate {
447        id: resolver.resolve_string(&dto.id)?,
448        labels: resolver.resolve_string_vec(&dto.labels)?,
449        properties: dto.properties.clone(),
450        from: resolver.resolve_optional_string(&dto.from)?,
451        to: resolver.resolve_optional_string(&dto.to)?,
452    })
453}
454
455fn map_element_mapping(
456    dto: &ElementMappingConfigDto,
457    resolver: &DtoMapper,
458) -> Result<ElementMappingConfig, MappingError> {
459    Ok(ElementMappingConfig {
460        element_type: map_element_type(&dto.element_type),
461        template: map_element_template(&dto.template, resolver)?,
462    })
463}
464
465fn map_response_config(
466    dto: &ResponseConfigDto,
467    resolver: &DtoMapper,
468) -> Result<ResponseConfig, MappingError> {
469    Ok(ResponseConfig {
470        items_path: resolver.resolve_string(&dto.items_path)?,
471        content_type: dto.content_type.as_ref().map(map_content_type_override),
472        mappings: dto
473            .mappings
474            .iter()
475            .map(|m| map_element_mapping(m, resolver))
476            .collect::<Result<Vec<_>, _>>()?,
477    })
478}
479
480fn map_endpoint_config(
481    dto: &EndpointConfigDto,
482    resolver: &DtoMapper,
483) -> Result<EndpointConfig, MappingError> {
484    Ok(EndpointConfig {
485        url: resolver.resolve_string(&dto.url)?,
486        method: map_http_method(&dto.method),
487        headers: dto
488            .headers
489            .iter()
490            .map(|(k, v)| Ok((k.clone(), resolver.resolve_string(v)?)))
491            .collect::<Result<HashMap<_, _>, MappingError>>()?,
492        body: dto.body.clone(),
493        auth: dto
494            .auth
495            .as_ref()
496            .map(|a| map_auth_config(a, resolver))
497            .transpose()?,
498        pagination: dto
499            .pagination
500            .as_ref()
501            .map(|p| map_pagination_config(p, resolver))
502            .transpose()?,
503        response: map_response_config(&dto.response, resolver)?,
504    })
505}
506
507fn map_config(
508    dto: &HttpBootstrapConfigDto,
509    resolver: &DtoMapper,
510) -> Result<HttpBootstrapConfig, MappingError> {
511    Ok(HttpBootstrapConfig {
512        endpoints: dto
513            .endpoints
514            .iter()
515            .map(|e| map_endpoint_config(e, resolver))
516            .collect::<Result<Vec<_>, _>>()?,
517        timeout_seconds: resolver.resolve_typed(&dto.timeout_seconds)?,
518        max_retries: resolver.resolve_typed(&dto.max_retries)?,
519        retry_delay_ms: resolver.resolve_typed(&dto.retry_delay_ms)?,
520        max_pages: dto
521            .max_pages
522            .as_ref()
523            .map(|v| resolver.resolve_typed(v))
524            .transpose()?,
525    })
526}
527
528// ── OpenAPI schema registration ─────────────────────────────────────────────
529
530#[derive(OpenApi)]
531#[openapi(components(schemas(
532    HttpBootstrapConfigDto,
533    EndpointConfigDto,
534    HttpMethodDto,
535    AuthConfigDto,
536    ApiKeyLocationDto,
537    PaginationConfigDto,
538    ResponseConfigDto,
539    ContentTypeOverrideDto,
540    ElementMappingConfigDto,
541    ElementTypeDto,
542    ElementTemplateDto,
543)))]
544struct HttpBootstrapSchemas;
545
546// ── Descriptor ──────────────────────────────────────────────────────────────
547
548/// Plugin descriptor for the HTTP bootstrap provider.
549pub struct HttpBootstrapDescriptor;
550
551#[async_trait]
552impl BootstrapPluginDescriptor for HttpBootstrapDescriptor {
553    fn kind(&self) -> &str {
554        "http"
555    }
556
557    fn config_version(&self) -> &str {
558        "1.0.0"
559    }
560
561    fn config_schema_name(&self) -> &str {
562        "bootstrap.http.HttpBootstrapConfig"
563    }
564
565    fn config_schema_json(&self) -> String {
566        let api = HttpBootstrapSchemas::openapi();
567        serde_json::to_string(
568            &api.components
569                .as_ref()
570                .expect("OpenAPI components missing")
571                .schemas,
572        )
573        .expect("Failed to serialize config schema")
574    }
575
576    async fn create_bootstrap_provider(
577        &self,
578        config_json: &serde_json::Value,
579        _source_config_json: &serde_json::Value,
580    ) -> anyhow::Result<Box<dyn BootstrapProvider>> {
581        let dto: HttpBootstrapConfigDto = serde_json::from_value(config_json.clone())
582            .map_err(|e| anyhow::anyhow!("Failed to parse HTTP bootstrap config: {e}"))?;
583
584        let mapper = DtoMapper::new();
585        let config = map_config(&dto, &mapper)
586            .map_err(|e| anyhow::anyhow!("Failed to resolve HTTP bootstrap config: {e}"))?;
587
588        config.validate()?;
589
590        let provider = HttpBootstrapProvider::new(config)?;
591        Ok(Box::new(provider))
592    }
593}