1use code_system_graph_model::{
2 Evidence, EvidenceId, Node, NodeId, NodeKind, Provenance, RepoId, stable_id
3};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::{ExtractionBudgets, ExtractionLimitExceeded, ExtractionTracker, HttpConsumerConfig};
8
9const HTTP_METHODS: [&str; 8] = [
10 "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE",
11];
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum BoundaryRole {
17 Provider,
19 Consumer,
21}
22
23#[derive(Debug, Clone, PartialEq)]
25pub struct HttpBoundary {
26 pub node: Node,
28 pub method: String,
30 pub path: String,
32 pub role: BoundaryRole,
34 pub evidence: Evidence,
36}
37
38impl HttpBoundary {
39 #[must_use]
41 pub fn consumer(repo_id: RepoId, config: &HttpConsumerConfig) -> Self {
42 let method = config.method.trim().to_ascii_uppercase();
43 let path = normalize_http_path(&config.path);
44 boundary(
45 repo_id,
46 &method,
47 &path,
48 BoundaryRole::Consumer,
49 &config.source,
50 1.0,
51 )
52 }
53}
54
55#[derive(Debug, Error)]
57pub enum HttpExtractionError {
58 #[error("invalid OpenAPI document: parser rejected malformed input")]
60 InvalidDocument,
61 #[error("invalid OpenAPI document: root must be an object")]
63 InvalidRoot,
64 #[error("unsupported HTTP contract version; expected OpenAPI 3.x or Swagger 2.0")]
66 UnsupportedVersion,
67 #[error("invalid OpenAPI document: `paths` must be an object")]
69 InvalidPaths,
70 #[error(transparent)]
72 LimitExceeded(#[from] ExtractionLimitExceeded),
73}
74
75#[must_use]
77pub fn normalize_http_path(path: &str) -> String {
78 let trimmed = path.trim();
79 if trimmed.is_empty() || trimmed == "/" {
80 return "/".to_owned();
81 }
82
83 let mut normalized = String::with_capacity(trimmed.len() + 1);
84 if !trimmed.starts_with('/') {
85 normalized.push('/');
86 }
87 let mut previous_slash = false;
88 for character in trimmed.chars() {
89 if character == '/' {
90 if !previous_slash {
91 normalized.push(character);
92 }
93 previous_slash = true;
94 } else {
95 normalized.push(character);
96 previous_slash = false;
97 }
98 }
99 while normalized.len() > 1 && normalized.ends_with('/') {
100 normalized.pop();
101 }
102 normalized
103}
104
105pub fn extract_openapi(
112 repo_id: &RepoId,
113 source_path: &str,
114 input: &str,
115) -> Result<Vec<HttpBoundary>, HttpExtractionError> {
116 let mut tracker = ExtractionTracker::new(
117 source_path,
118 "code-system-graph.http.openapi",
119 &ExtractionBudgets::default(),
120 );
121 extract_openapi_with_tracker(repo_id, source_path, input, &mut tracker)
122}
123
124pub fn extract_openapi_with_tracker(
131 repo_id: &RepoId,
132 source_path: &str,
133 input: &str,
134 tracker: &mut ExtractionTracker,
135) -> Result<Vec<HttpBoundary>, HttpExtractionError> {
136 tracker.check_input_bytes(u64::try_from(input.len()).unwrap_or(u64::MAX))?;
137 tracker.charge_portable_path(source_path)?;
138 tracker.check_structured_time()?;
139 precheck_openapi_depth(input, tracker)?;
140 let parsed = crate::yaml::from_str_with_extraction_budgets(input, tracker.budgets());
141 tracker.check_structured_time()?;
142 let document: serde_json::Value =
143 parsed.map_err(|error| openapi_yaml_error(&error, tracker))?;
144 charge_openapi_document(&document, tracker)?;
145 let root = document
146 .as_object()
147 .ok_or(HttpExtractionError::InvalidRoot)?;
148 let openapi_version = root.get("openapi").and_then(serde_json::Value::as_str);
149 let swagger_version = root.get("swagger").and_then(serde_json::Value::as_str);
150 let is_openapi = openapi_version.is_some_and(|version| version.starts_with("3."));
151 let is_swagger = swagger_version == Some("2.0");
152 if !is_openapi && !is_swagger {
153 return Err(HttpExtractionError::UnsupportedVersion);
154 }
155 let base_path = if is_swagger {
156 root.get("basePath")
157 .and_then(serde_json::Value::as_str)
158 .map(|value| {
159 tracker.charge_portable_path(value)?;
160 Ok::<_, ExtractionLimitExceeded>(normalize_http_path(value))
161 })
162 .transpose()?
163 } else {
164 None
165 };
166 let paths = root
167 .get("paths")
168 .and_then(serde_json::Value::as_object)
169 .ok_or(HttpExtractionError::InvalidPaths)?;
170
171 let mut boundaries = Vec::new();
172 for (raw_path, path_item) in paths {
173 tracker.charge_work(1)?;
174 tracker.charge_portable_path(raw_path)?;
175 let Some(operations) = path_item.as_object() else {
176 continue;
177 };
178 let path = base_path.as_ref().map_or_else(
179 || normalize_http_path(raw_path),
180 |base| normalize_http_path(&format!("{base}/{raw_path}")),
181 );
182 tracker.charge_portable_path(&path)?;
183 for (raw_method, operation) in operations {
184 tracker.charge_work(1)?;
185 let Some(method) = HTTP_METHODS
186 .iter()
187 .find(|candidate| raw_method.eq_ignore_ascii_case(candidate))
188 .copied()
189 else {
190 continue;
191 };
192 if !operation.is_object() {
193 continue;
194 }
195 tracker.charge_identifier(method)?;
196 tracker.charge_observation(1)?;
197 boundaries.push(boundary(
198 repo_id.clone(),
199 method,
200 &path,
201 BoundaryRole::Provider,
202 source_path,
203 1.0,
204 ));
205 }
206 }
207 boundaries.sort_by(|left, right| (&left.path, &left.method).cmp(&(&right.path, &right.method)));
208 tracker.check_structured_time()?;
209 Ok(boundaries)
210}
211
212fn precheck_openapi_depth(
213 input: &str,
214 tracker: &mut ExtractionTracker,
215) -> Result<(), ExtractionLimitExceeded> {
216 let mut block_indents = Vec::new();
217 let mut flow_depth = 0_u64;
218 let mut quote = None;
219 let mut escaped = false;
220 let mut block_scalar: Option<(usize, u64)> = None;
221 let mut inspected = 0_u64;
222
223 for source_line in input.lines() {
224 let indentation = source_line
225 .as_bytes()
226 .iter()
227 .take_while(|byte| **byte == b' ')
228 .count();
229 let trimmed = source_line.trim();
230 if let Some((parent_indent, scalar_bytes)) = block_scalar {
231 if trimmed.is_empty() || indentation > parent_indent {
232 let content_bytes = source_line.len().saturating_sub(indentation);
233 let observed = scalar_bytes
234 .saturating_add(u64::try_from(content_bytes).unwrap_or(u64::MAX))
235 .saturating_add(1);
236 tracker.check_string_bytes(observed)?;
237 block_scalar = Some((parent_indent, observed));
238 continue;
239 }
240 block_scalar = None;
241 }
242 if trimmed.is_empty()
243 || trimmed.starts_with('#')
244 || matches!(trimmed, "---" | "...")
245 || trimmed.starts_with('%')
246 {
247 continue;
248 }
249 tracker.charge_work(1)?;
250 precheck_yaml_scalar_values(source_line, tracker)?;
251 while block_indents
252 .last()
253 .is_some_and(|parent| *parent >= indentation)
254 {
255 block_indents.pop();
256 }
257 let block_depth = u64::try_from(block_indents.len())
258 .unwrap_or(u64::MAX)
259 .saturating_add(1);
260 let compact_sequence_depth = compact_yaml_sequence_depth(trimmed);
261 tracker.check_structural_depth(
262 block_depth
263 .saturating_add(flow_depth)
264 .saturating_add(compact_sequence_depth),
265 )?;
266
267 let mut comment = false;
268 for current in source_line.chars() {
269 inspected = inspected.saturating_add(1);
270 if inspected.is_multiple_of(1_024) {
271 tracker.check_structured_time()?;
272 }
273 if comment {
274 continue;
275 }
276 if let Some(delimiter) = quote {
277 if delimiter == '"' && escaped {
278 escaped = false;
279 } else if delimiter == '"' && current == '\\' {
280 escaped = true;
281 } else if current == delimiter {
282 quote = None;
283 }
284 continue;
285 }
286 match current {
287 '"' | '\'' => quote = Some(current),
288 '#' => comment = true,
289 '{' | '[' => {
290 flow_depth = flow_depth.saturating_add(1);
291 tracker.check_structural_depth(block_depth.saturating_add(flow_depth))?;
292 }
293 '}' | ']' => flow_depth = flow_depth.saturating_sub(1),
294 _ => {}
295 }
296 }
297 quote = None;
298 escaped = false;
299 let structural = trimmed.split('#').next().unwrap_or(trimmed).trim_end();
300 if structural.ends_with(['|', '>']) {
301 block_scalar = Some((indentation, 0_u64));
302 } else if structural == "-" || structural.ends_with(':') {
303 tracker.check_structural_depth(
304 block_depth
305 .saturating_add(flow_depth)
306 .saturating_add(compact_sequence_depth)
307 .saturating_add(1),
308 )?;
309 block_indents.push(indentation);
310 }
311 }
312 tracker.check_structured_time()?;
313 Ok(())
314}
315
316fn precheck_yaml_scalar_values(
317 source_line: &str,
318 tracker: &ExtractionTracker,
319) -> Result<(), ExtractionLimitExceeded> {
320 let bytes = source_line.as_bytes();
321 let mut quote = None;
322 let mut quote_start = 0_usize;
323 let mut escaped = false;
324 let mut colon = None;
325 let mut comment = bytes.len();
326 let mut cursor = 0_usize;
327 while cursor < bytes.len() {
328 let byte = bytes[cursor];
329 if let Some(delimiter) = quote {
330 if delimiter == b'"' && escaped {
331 escaped = false;
332 } else if delimiter == b'"' && byte == b'\\' {
333 escaped = true;
334 } else if byte == delimiter {
335 let observed =
336 u64::try_from(cursor.saturating_sub(quote_start)).unwrap_or(u64::MAX);
337 tracker.check_string_bytes(observed)?;
338 quote = None;
339 }
340 } else {
341 match byte {
342 b'"' | b'\'' => {
343 quote = Some(byte);
344 quote_start = cursor.saturating_add(1);
345 }
346 b'#' => {
347 comment = cursor;
348 break;
349 }
350 b':' if colon.is_none() => colon = Some(cursor),
351 _ => {}
352 }
353 }
354 cursor = cursor.saturating_add(1);
355 }
356
357 let meaningful = source_line[..comment].trim();
358 let sequence_value = meaningful
359 .strip_prefix('-')
360 .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
361 .map(str::trim_start);
362 if let Some(colon) = colon.filter(|colon| *colon < comment) {
363 let key = source_line[..colon].trim().trim_start_matches('-').trim();
364 tracker.check_string_bytes(u64::try_from(key.len()).unwrap_or(u64::MAX))?;
365 let value = source_line[colon.saturating_add(1)..comment].trim();
366 if !value.is_empty() && !value.starts_with(['"', '\'', '{', '[', '|', '>', '&', '*', '!']) {
367 tracker.check_string_bytes(u64::try_from(value.len()).unwrap_or(u64::MAX))?;
368 }
369 } else if let Some(value) = sequence_value
370 && !value.is_empty()
371 && !value.starts_with(['"', '\'', '{', '[', '|', '>', '&', '*', '!'])
372 {
373 tracker.check_string_bytes(u64::try_from(value.len()).unwrap_or(u64::MAX))?;
374 }
375 Ok(())
376}
377
378fn openapi_yaml_error(
379 error: &serde_saphyr::DeserializeError,
380 tracker: &ExtractionTracker,
381) -> HttpExtractionError {
382 use serde_saphyr::budget::BudgetBreach;
383
384 let checked = match error {
385 serde_saphyr::Error::Budget { breach, .. } => match breach {
386 BudgetBreach::Events { events } => tracker.check_work_units(to_u64(*events)),
387 BudgetBreach::Aliases { aliases } => tracker.check_work_units(to_u64(*aliases)),
388 BudgetBreach::Anchors { anchors } => tracker.check_work_units(to_u64(*anchors)),
389 BudgetBreach::Depth { depth } => tracker.check_structural_depth(to_u64(*depth)),
390 BudgetBreach::Nodes { nodes } => tracker.check_work_units(to_u64(*nodes)),
391 BudgetBreach::ScalarBytes { total_scalar_bytes } => {
392 tracker.check_accumulated_string_bytes(to_u64(*total_scalar_bytes))
393 }
394 BudgetBreach::CommentBytes {
395 total_comment_bytes,
396 } => tracker.check_input_bytes(to_u64(*total_comment_bytes)),
397 BudgetBreach::MergeKeys { merge_keys } => tracker.check_work_units(to_u64(*merge_keys)),
398 BudgetBreach::InputBytes { input_bytes } => {
399 tracker.check_input_bytes(to_u64(*input_bytes))
400 }
401 _ => return HttpExtractionError::InvalidDocument,
402 },
403 serde_saphyr::Error::AliasReplayCounterOverflow { .. } => {
404 tracker.check_work_units(u64::MAX)
405 }
406 serde_saphyr::Error::AliasReplayLimitExceeded {
407 total_replayed_events,
408 ..
409 } => tracker.check_work_units(to_u64(*total_replayed_events)),
410 serde_saphyr::Error::AliasExpansionLimitExceeded { expansions, .. } => {
411 tracker.check_work_units(to_u64(*expansions))
412 }
413 serde_saphyr::Error::AliasReplayStackDepthExceeded { depth, .. } => {
414 tracker.check_structural_depth(to_u64(*depth))
415 }
416 _ => return HttpExtractionError::InvalidDocument,
417 };
418 checked.err().map_or(
419 HttpExtractionError::InvalidDocument,
420 HttpExtractionError::from,
421 )
422}
423
424fn to_u64(value: usize) -> u64 {
425 u64::try_from(value).unwrap_or(u64::MAX)
426}
427
428fn compact_yaml_sequence_depth(mut value: &str) -> u64 {
429 let mut depth = 0_u64;
430 loop {
431 let Some(remainder) = value.strip_prefix('-') else {
432 return depth;
433 };
434 if !remainder.is_empty()
435 && !remainder
436 .as_bytes()
437 .first()
438 .is_some_and(u8::is_ascii_whitespace)
439 {
440 return depth;
441 }
442 depth = depth.saturating_add(1);
443 value = remainder.trim_start();
444 }
445}
446
447fn charge_openapi_document(
448 document: &serde_json::Value,
449 tracker: &mut ExtractionTracker,
450) -> Result<(), ExtractionLimitExceeded> {
451 tracker.check_structural_depth(1)?;
452 tracker.charge_work(1)?;
453 let mut pending = vec![(document, 1_u64)];
454 while let Some((value, depth)) = pending.pop() {
455 match value {
456 serde_json::Value::Object(object) => {
457 let child_depth = depth.saturating_add(1);
458 for (key, child) in object {
459 tracker.charge_string(key)?;
460 tracker.check_structural_depth(child_depth)?;
461 tracker.charge_work(1)?;
462 pending.push((child, child_depth));
463 }
464 }
465 serde_json::Value::Array(array) => {
466 let child_depth = depth.saturating_add(1);
467 for child in array {
468 tracker.check_structural_depth(child_depth)?;
469 tracker.charge_work(1)?;
470 pending.push((child, child_depth));
471 }
472 }
473 serde_json::Value::String(value) => tracker.charge_string(value)?,
474 serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {
475 }
476 }
477 }
478 Ok(())
479}
480
481fn boundary(
482 repo_id: RepoId,
483 method: &str,
484 path: &str,
485 role: BoundaryRole,
486 source_path: &str,
487 confidence: f32,
488) -> HttpBoundary {
489 let (role_key, extractor, provenance) = match role {
490 BoundaryRole::Provider => (
491 "provider",
492 "code-system-graph.http.openapi",
493 Provenance::Extracted,
494 ),
495 BoundaryRole::Consumer => (
496 "consumer",
497 "code-system-graph.http.declared",
498 Provenance::Declared,
499 ),
500 };
501 let stable_key = format!("http:{}:{role_key}:{method}:{path}", repo_id.as_str());
502 let evidence_key = format!("{stable_key}:{source_path}");
503 HttpBoundary {
504 node: Node {
505 id: NodeId::new(stable_id("node", &stable_key)),
506 kind: NodeKind::HttpOperation,
507 repo_id: Some(repo_id.clone()),
508 stable_key,
509 label: format!("{method} {path}"),
510 },
511 method: method.to_owned(),
512 path: path.to_owned(),
513 role,
514 evidence: Evidence {
515 id: EvidenceId::new(stable_id("evidence", &evidence_key)),
516 repo_id: Some(repo_id),
517 file_path: Some(source_path.to_owned()),
518 start_line: None,
519 end_line: None,
520 extractor: extractor.to_owned(),
521 extractor_version: env!("CARGO_PKG_VERSION").to_owned(),
522 provenance,
523 confidence,
524 observed_at_commit: None,
525 content_hash: Some(stable_id("content", &evidence_key)),
526 note: None,
527 },
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use code_system_graph_model::RepoId;
534
535 use super::{
536 extract_openapi, extract_openapi_with_tracker, normalize_http_path, precheck_openapi_depth
537 };
538 use crate::{ExtractionBudgets, ExtractionResource, ExtractionTracker};
539
540 #[test]
541 fn normalize_http_path_should_collapse_slashes_and_trailing_separator() {
542 assert_eq!(normalize_http_path("api//orders/"), "/api/orders");
543 }
544
545 #[test]
546 fn extract_openapi_should_return_sorted_provider_operations() {
547 let input = r"
548openapi: 3.1.0
549paths:
550 /api/orders:
551 post:
552 operationId: createOrder
553 get:
554 operationId: listOrders
555";
556 let result = extract_openapi(&RepoId::new("repo:api"), "openapi.yaml", input);
557 let methods = result.map(|items| {
558 items
559 .into_iter()
560 .map(|item| item.method)
561 .collect::<Vec<_>>()
562 });
563
564 assert!(matches!(
565 methods,
566 Ok(value) if value == vec!["GET".to_owned(), "POST".to_owned()]
567 ));
568 }
569
570 #[test]
571 fn tracked_openapi_should_enforce_observation_and_structural_budgets() {
572 let input = r"
573openapi: 3.1.0
574paths:
575 /orders:
576 get: {}
577 post: {}
578";
579 let observations = ExtractionBudgets {
580 max_observations_per_artifact: 1,
581 ..ExtractionBudgets::default()
582 };
583 let mut observation_tracker =
584 ExtractionTracker::new("openapi.yaml", "openapi", &observations);
585 assert!(matches!(
586 extract_openapi_with_tracker(
587 &RepoId::new("repo:api"),
588 "openapi.yaml",
589 input,
590 &mut observation_tracker,
591 ),
592 Err(super::HttpExtractionError::LimitExceeded(error))
593 if error.resource == ExtractionResource::Observations
594 ));
595
596 let depth = ExtractionBudgets {
597 max_structural_depth_per_artifact: 3,
598 ..ExtractionBudgets::default()
599 };
600 let mut depth_tracker = ExtractionTracker::new("openapi.yaml", "openapi", &depth);
601 assert!(matches!(
602 extract_openapi_with_tracker(
603 &RepoId::new("repo:api"),
604 "openapi.yaml",
605 input,
606 &mut depth_tracker,
607 ),
608 Err(super::HttpExtractionError::LimitExceeded(error))
609 if error.resource == ExtractionResource::StructuralDepth
610 ));
611 }
612
613 #[test]
614 fn openapi_precheck_should_accept_depth_64_and_reject_65_before_parsing() {
615 fn nested_openapi(depth: usize) -> String {
616 let mut source = String::from("openapi: 3.1.0\npaths: {}\n");
617 for level in 0..depth.saturating_sub(1) {
618 source.push_str(&" ".repeat(level));
619 source.push_str("x-");
620 source.push_str(&level.to_string());
621 source.push_str(":\n");
622 }
623 source
624 }
625
626 let budgets = ExtractionBudgets {
627 max_structural_depth_per_artifact: 64,
628 ..ExtractionBudgets::default()
629 };
630 let mut exact = ExtractionTracker::new("exact.yaml", "openapi", &budgets);
631 let mut above = ExtractionTracker::new("above.yaml", "openapi", &budgets);
632 assert!(
633 extract_openapi_with_tracker(
634 &RepoId::new("repo:api"),
635 "exact.yaml",
636 &nested_openapi(64),
637 &mut exact,
638 )
639 .is_ok()
640 );
641 assert!(matches!(
642 extract_openapi_with_tracker(
643 &RepoId::new("repo:api"),
644 "above.yaml",
645 &nested_openapi(65),
646 &mut above,
647 ),
648 Err(super::HttpExtractionError::LimitExceeded(error))
649 if error.resource == ExtractionResource::StructuralDepth
650 && error.observed == 65
651 && error.maximum == 64
652 ));
653 }
654
655 #[test]
656 fn openapi_precheck_should_count_compact_yaml_sequence_depth() {
657 fn compact_sequence(depth: usize) -> String {
658 format!("x:\n {}value\n", "- ".repeat(depth))
659 }
660
661 let budgets = ExtractionBudgets {
662 max_structural_depth_per_artifact: 64,
663 ..ExtractionBudgets::default()
664 };
665 let mut exact = ExtractionTracker::new("exact.yaml", "openapi", &budgets);
666 let mut above = ExtractionTracker::new("above.yaml", "openapi", &budgets);
667 assert!(precheck_openapi_depth(&compact_sequence(62), &mut exact).is_ok());
668 assert!(matches!(
669 precheck_openapi_depth(&compact_sequence(63), &mut above),
670 Err(error)
671 if error.resource == ExtractionResource::StructuralDepth
672 && error.observed == 65
673 && error.maximum == 64
674 ));
675 }
676
677 #[test]
678 fn openapi_parser_should_honor_structural_overrides_above_its_library_default() {
679 let mut input = String::from("openapi: 3.1.0\npaths: {}\nroot:\n");
680 for depth in 1..80 {
681 input.push_str(&" ".repeat(depth));
682 input.push_str("child:\n");
683 }
684 input.push_str(&" ".repeat(80));
685 input.push_str("value: true\n");
686 let budgets = ExtractionBudgets {
687 max_structural_depth_per_artifact: 96,
688 ..ExtractionBudgets::default()
689 };
690 let mut tracker = ExtractionTracker::new("deep.yaml", "openapi", &budgets);
691
692 let result = extract_openapi_with_tracker(
693 &RepoId::new("repo:api"),
694 "deep.yaml",
695 &input,
696 &mut tracker,
697 );
698 assert!(
699 result.is_ok(),
700 "configured depth was not honored: {result:?}"
701 );
702 }
703
704 #[test]
705 fn openapi_scalar_limit_should_apply_before_yaml_dom_materialization() {
706 let secret = "sensitive-scalar-value";
707 let input = format!("openapi: 3.1.0\ninfo:\n description: {secret}\npaths: {{}}\n");
708 let budgets = ExtractionBudgets {
709 max_string_bytes_per_value: 12,
710 ..ExtractionBudgets::default()
711 };
712 let mut tracker = ExtractionTracker::new("secret.yaml", "openapi", &budgets);
713
714 let result = extract_openapi_with_tracker(
715 &RepoId::new("repo:api"),
716 "secret.yaml",
717 &input,
718 &mut tracker,
719 );
720 assert!(
721 matches!(
722 &result,
723 Err(super::HttpExtractionError::LimitExceeded(error))
724 if error.resource == ExtractionResource::StringBytesPerValue
725 && error.observed == u64::try_from(secret.len()).unwrap_or(u64::MAX)
726 && error.maximum == 12
727 ),
728 "unexpected scalar budget result: {result:?}"
729 );
730 let diagnostic = format!("{result:?} {result:?}");
731 assert!(!diagnostic.contains(secret));
732 }
733
734 #[test]
735 fn malformed_openapi_error_should_not_echo_source_literals() {
736 let secret = "sensitive-duplicate-key-value";
737 let input =
738 format!("openapi: 3.1.0\ninfo:\n {secret}: first\n {secret}: second\npaths: {{}}\n");
739 let result = extract_openapi(&RepoId::new("repo:api"), "invalid.yaml", &input);
740
741 assert!(matches!(
742 &result,
743 Err(super::HttpExtractionError::InvalidDocument)
744 ));
745 let diagnostic = format!(
746 "{result:?} {}",
747 result.as_ref().expect_err("malformed input")
748 );
749 assert!(!diagnostic.contains(secret));
750 }
751
752 #[test]
753 fn yaml_node_budget_should_abort_before_openapi_dom_materialization() {
754 let input = "openapi: 3.1.0\npaths: { /orders: { get: {} } }\n";
755 let budgets = ExtractionBudgets {
756 max_work_units_per_artifact: 4,
757 ..ExtractionBudgets::default()
758 };
759 let mut tracker = ExtractionTracker::new("nodes.yaml", "openapi", &budgets);
760
761 assert!(matches!(
762 extract_openapi_with_tracker(
763 &RepoId::new("repo:api"),
764 "nodes.yaml",
765 input,
766 &mut tracker,
767 ),
768 Err(super::HttpExtractionError::LimitExceeded(error))
769 if error.resource == ExtractionResource::WorkUnits
770 && error.observed == 5
771 && error.maximum == 4
772 ));
773 }
774
775 #[test]
776 fn extract_swagger_should_conservatively_apply_base_path() {
777 let input = r#"
778swagger: "2.0"
779basePath: /api
780paths:
781 /orders/{id}:
782 get:
783 operationId: getOrder
784"#;
785 let result = extract_openapi(&RepoId::new("repo:api"), "swagger.yaml", input);
786
787 assert!(matches!(
788 result,
789 Ok(boundaries)
790 if boundaries.len() == 1
791 && boundaries[0].method == "GET"
792 && boundaries[0].path == "/api/orders/{id}"
793 ));
794 }
795}