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
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#[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
546pub 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}