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
350async fn map_auth_config(
351    dto: &AuthConfigDto,
352    resolver: &DtoMapper,
353) -> Result<AuthConfig, MappingError> {
354    match dto {
355        AuthConfigDto::Bearer { token_env } => Ok(AuthConfig::Bearer {
356            token_env: resolver.resolve_string(token_env).await?,
357        }),
358        AuthConfigDto::ApiKey {
359            location,
360            name,
361            value_env,
362        } => Ok(AuthConfig::ApiKey {
363            location: map_api_key_location(location),
364            name: resolver.resolve_string(name).await?,
365            value_env: resolver.resolve_string(value_env).await?,
366        }),
367        AuthConfigDto::Basic {
368            username_env,
369            password_env,
370        } => Ok(AuthConfig::Basic {
371            username_env: resolver.resolve_string(username_env).await?,
372            password_env: resolver.resolve_optional_string(password_env).await?,
373        }),
374        AuthConfigDto::OAuth2ClientCredentials {
375            token_url,
376            client_id_env,
377            client_secret_env,
378            scopes,
379        } => Ok(AuthConfig::OAuth2ClientCredentials {
380            token_url: resolver.resolve_string(token_url).await?,
381            client_id_env: resolver.resolve_string(client_id_env).await?,
382            client_secret_env: resolver.resolve_string(client_secret_env).await?,
383            scopes: resolver.resolve_string_vec(scopes).await?,
384        }),
385    }
386}
387
388async fn map_pagination_config(
389    dto: &PaginationConfigDto,
390    resolver: &DtoMapper,
391) -> Result<PaginationConfig, MappingError> {
392    match dto {
393        PaginationConfigDto::OffsetLimit {
394            offset_param,
395            limit_param,
396            page_size,
397            total_path,
398        } => Ok(PaginationConfig::OffsetLimit {
399            offset_param: resolver.resolve_string(offset_param).await?,
400            limit_param: resolver.resolve_string(limit_param).await?,
401            page_size: resolver.resolve_typed(page_size).await?,
402            total_path: resolver.resolve_optional_string(total_path).await?,
403        }),
404        PaginationConfigDto::PageNumber {
405            page_param,
406            page_size_param,
407            page_size,
408            total_pages_path,
409        } => Ok(PaginationConfig::PageNumber {
410            page_param: resolver.resolve_string(page_param).await?,
411            page_size_param: resolver.resolve_string(page_size_param).await?,
412            page_size: resolver.resolve_typed(page_size).await?,
413            total_pages_path: resolver.resolve_optional_string(total_pages_path).await?,
414        }),
415        PaginationConfigDto::Cursor {
416            cursor_param,
417            cursor_path,
418            has_more_path,
419            page_size_param,
420            page_size,
421        } => Ok(PaginationConfig::Cursor {
422            cursor_param: resolver.resolve_string(cursor_param).await?,
423            cursor_path: resolver.resolve_string(cursor_path).await?,
424            has_more_path: resolver.resolve_optional_string(has_more_path).await?,
425            page_size_param: resolver.resolve_optional_string(page_size_param).await?,
426            page_size: resolver.resolve_optional(page_size).await?,
427        }),
428        PaginationConfigDto::LinkHeader {
429            page_size_param,
430            page_size,
431        } => Ok(PaginationConfig::LinkHeader {
432            page_size_param: resolver.resolve_optional_string(page_size_param).await?,
433            page_size: resolver.resolve_optional(page_size).await?,
434        }),
435        PaginationConfigDto::NextUrl {
436            next_url_path,
437            base_url,
438        } => Ok(PaginationConfig::NextUrl {
439            next_url_path: resolver.resolve_string(next_url_path).await?,
440            base_url: resolver.resolve_optional_string(base_url).await?,
441        }),
442    }
443}
444
445async fn map_element_template(
446    dto: &ElementTemplateDto,
447    resolver: &DtoMapper,
448) -> Result<ElementTemplate, MappingError> {
449    Ok(ElementTemplate {
450        id: resolver.resolve_string(&dto.id).await?,
451        labels: resolver.resolve_string_vec(&dto.labels).await?,
452        properties: dto.properties.clone(),
453        from: resolver.resolve_optional_string(&dto.from).await?,
454        to: resolver.resolve_optional_string(&dto.to).await?,
455    })
456}
457
458async fn map_element_mapping(
459    dto: &ElementMappingConfigDto,
460    resolver: &DtoMapper,
461) -> Result<ElementMappingConfig, MappingError> {
462    Ok(ElementMappingConfig {
463        element_type: map_element_type(&dto.element_type),
464        template: map_element_template(&dto.template, resolver).await?,
465    })
466}
467
468async fn map_response_config(
469    dto: &ResponseConfigDto,
470    resolver: &DtoMapper,
471) -> Result<ResponseConfig, MappingError> {
472    let mut mappings = Vec::with_capacity(dto.mappings.len());
473    for mapping in &dto.mappings {
474        mappings.push(map_element_mapping(mapping, resolver).await?);
475    }
476
477    Ok(ResponseConfig {
478        items_path: resolver.resolve_string(&dto.items_path).await?,
479        content_type: dto.content_type.as_ref().map(map_content_type_override),
480        mappings,
481    })
482}
483
484async fn map_endpoint_config(
485    dto: &EndpointConfigDto,
486    resolver: &DtoMapper,
487) -> Result<EndpointConfig, MappingError> {
488    let mut headers = HashMap::with_capacity(dto.headers.len());
489    for (key, value) in &dto.headers {
490        headers.insert(key.clone(), resolver.resolve_string(value).await?);
491    }
492
493    let auth = match &dto.auth {
494        Some(auth) => Some(map_auth_config(auth, resolver).await?),
495        None => None,
496    };
497
498    let pagination = match &dto.pagination {
499        Some(pagination) => Some(map_pagination_config(pagination, resolver).await?),
500        None => None,
501    };
502
503    Ok(EndpointConfig {
504        url: resolver.resolve_string(&dto.url).await?,
505        method: map_http_method(&dto.method),
506        headers,
507        body: dto.body.clone(),
508        auth,
509        pagination,
510        response: map_response_config(&dto.response, resolver).await?,
511    })
512}
513
514async fn map_config(
515    dto: &HttpBootstrapConfigDto,
516    resolver: &DtoMapper,
517) -> Result<HttpBootstrapConfig, MappingError> {
518    let mut endpoints = Vec::with_capacity(dto.endpoints.len());
519    for endpoint in &dto.endpoints {
520        endpoints.push(map_endpoint_config(endpoint, resolver).await?);
521    }
522
523    let max_pages = match &dto.max_pages {
524        Some(value) => Some(resolver.resolve_typed(value).await?),
525        None => None,
526    };
527
528    Ok(HttpBootstrapConfig {
529        endpoints,
530        timeout_seconds: resolver.resolve_typed(&dto.timeout_seconds).await?,
531        max_retries: resolver.resolve_typed(&dto.max_retries).await?,
532        retry_delay_ms: resolver.resolve_typed(&dto.retry_delay_ms).await?,
533        max_pages,
534    })
535}
536
537// ── OpenAPI schema registration ─────────────────────────────────────────────
538
539#[derive(OpenApi)]
540#[openapi(components(schemas(
541    HttpBootstrapConfigDto,
542    EndpointConfigDto,
543    HttpMethodDto,
544    AuthConfigDto,
545    ApiKeyLocationDto,
546    PaginationConfigDto,
547    ResponseConfigDto,
548    ContentTypeOverrideDto,
549    ElementMappingConfigDto,
550    ElementTypeDto,
551    ElementTemplateDto,
552)))]
553struct HttpBootstrapSchemas;
554
555// ── Descriptor ──────────────────────────────────────────────────────────────
556
557/// Plugin descriptor for the HTTP bootstrap provider.
558pub struct HttpBootstrapDescriptor;
559
560#[async_trait]
561impl BootstrapPluginDescriptor for HttpBootstrapDescriptor {
562    fn kind(&self) -> &str {
563        "http"
564    }
565
566    fn config_version(&self) -> &str {
567        "1.0.0"
568    }
569
570    fn config_schema_name(&self) -> &str {
571        "bootstrap.http.HttpBootstrapConfig"
572    }
573
574    fn config_schema_json(&self) -> String {
575        let api = HttpBootstrapSchemas::openapi();
576        serde_json::to_string(
577            &api.components
578                .as_ref()
579                .expect("OpenAPI components missing")
580                .schemas,
581        )
582        .expect("Failed to serialize config schema")
583    }
584
585    async fn create_bootstrap_provider(
586        &self,
587        config_json: &serde_json::Value,
588        _source_config_json: &serde_json::Value,
589    ) -> anyhow::Result<Box<dyn BootstrapProvider>> {
590        let dto: HttpBootstrapConfigDto = serde_json::from_value(config_json.clone())
591            .map_err(|e| anyhow::anyhow!("Failed to parse HTTP bootstrap config: {e}"))?;
592
593        let mapper = DtoMapper::new();
594        let config = map_config(&dto, &mapper)
595            .await
596            .map_err(|e| anyhow::anyhow!("Failed to resolve HTTP bootstrap config: {e}"))?;
597
598        config.validate()?;
599
600        let provider = HttpBootstrapProvider::new(config)?;
601        Ok(Box::new(provider))
602    }
603}