1use 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#[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 #[schema(value_type = Vec<bootstrap::http::EndpointConfig>)]
38 pub endpoints: Vec<EndpointConfigDto>,
39
40 #[serde(default = "default_timeout")]
42 pub timeout_seconds: ConfigValue<u64>,
43
44 #[serde(default = "default_retries")]
46 pub max_retries: ConfigValue<u32>,
47
48 #[serde(default = "default_retry_delay")]
50 pub retry_delay_ms: ConfigValue<u64>,
51
52 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
71#[schema(as = bootstrap::http::EndpointConfig)]
72#[serde(rename_all = "camelCase")]
73pub struct EndpointConfigDto {
74 pub url: ConfigValue<String>,
76
77 #[serde(default = "default_method")]
79 #[schema(value_type = bootstrap::http::HttpMethod)]
80 pub method: HttpMethodDto,
81
82 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
84 pub headers: HashMap<String, ConfigValue<String>>,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub body: Option<serde_json::Value>,
89
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 #[schema(value_type = Option<bootstrap::http::AuthConfig>)]
93 pub auth: Option<AuthConfigDto>,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 #[schema(value_type = Option<bootstrap::http::PaginationConfig>)]
98 pub pagination: Option<PaginationConfigDto>,
99
100 #[schema(value_type = bootstrap::http::ResponseConfig)]
102 pub response: ResponseConfigDto,
103}
104
105fn default_method() -> HttpMethodDto {
106 HttpMethodDto::Get
107}
108
109#[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#[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 {
126 token_env: ConfigValue<String>,
128 },
129 ApiKey {
131 #[schema(value_type = bootstrap::http::ApiKeyLocation)]
133 location: ApiKeyLocationDto,
134 name: ConfigValue<String>,
136 value_env: ConfigValue<String>,
138 },
139 Basic {
141 username_env: ConfigValue<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 password_env: Option<ConfigValue<String>>,
146 },
147 #[serde(rename = "oauth2-client-credentials")]
149 OAuth2ClientCredentials {
150 token_url: ConfigValue<String>,
152 client_id_env: ConfigValue<String>,
154 client_secret_env: ConfigValue<String>,
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 scopes: Vec<ConfigValue<String>>,
159 },
160}
161
162#[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#[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 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 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 {
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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
240#[schema(as = bootstrap::http::ResponseConfig)]
241#[serde(rename_all = "camelCase")]
242pub struct ResponseConfigDto {
243 #[serde(default = "default_items_path")]
245 pub items_path: ConfigValue<String>,
246
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 #[schema(value_type = Option<bootstrap::http::ContentTypeOverride>)]
250 pub content_type: Option<ContentTypeOverrideDto>,
251
252 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
273#[schema(as = bootstrap::http::ElementMappingConfig)]
274#[serde(rename_all = "camelCase")]
275pub struct ElementMappingConfigDto {
276 #[schema(value_type = bootstrap::http::ElementType)]
278 pub element_type: ElementTypeDto,
279
280 #[schema(value_type = bootstrap::http::ElementTemplate)]
282 pub template: ElementTemplateDto,
283}
284
285#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
296#[schema(as = bootstrap::http::ElementTemplate)]
297#[serde(rename_all = "camelCase")]
298pub struct ElementTemplateDto {
299 pub id: ConfigValue<String>,
301
302 pub labels: Vec<ConfigValue<String>>,
304
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub properties: Option<serde_json::Value>,
308
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub from: Option<ConfigValue<String>>,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub to: Option<ConfigValue<String>>,
316}
317
318fn 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#[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
555pub 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}