1use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29
30use helm_schema_json_schema_walk::try_visit_subschemas_mut;
31use jsonschema::{Retrieve, Uri};
32use referencing::uri;
33use serde_json::{Map, Value};
34use tracing::instrument;
35use url::Url;
36
37use crate::error::{CliError, EngineResult};
38use crate::fetch_policy::FetchPolicy;
39use crate::load_budget::{LoadBudget, read_to_end_capped};
40
41#[instrument(skip_all)]
57pub fn flatten_refs(
58 schema: &Value,
59 base_dir: &Path,
60 fetch_policy: FetchPolicy,
61 load_budget: LoadBudget,
62) -> EngineResult<Value> {
63 let base_uri = directory_file_uri(base_dir)?;
64 let retriever = FsHttpRetrieve::new(fetch_policy, load_budget);
65 flatten_with_retriever(schema, &base_uri, retriever)
66}
67
68#[instrument(skip_all)]
80pub fn flatten_prepared_refs(schema: &Value, base_dir: &Path) -> EngineResult<Value> {
81 let base_uri = directory_file_uri(base_dir)?;
82 flatten_with_retriever(schema, &base_uri, NoExternalRetrieve)
83}
84
85#[instrument(skip_all)]
99pub fn bundle_refs(
100 schema: Value,
101 base_dir: &Path,
102 fetch_policy: FetchPolicy,
103 load_budget: LoadBudget,
104) -> EngineResult<Value> {
105 let mut namespace = BundleNamespace::default();
106 bundle_refs_in_namespace(schema, base_dir, fetch_policy, load_budget, &mut namespace)
107}
108
109pub(crate) fn bundle_refs_in_namespace(
111 schema: Value,
112 base_dir: &Path,
113 fetch_policy: FetchPolicy,
114 load_budget: LoadBudget,
115 namespace: &mut BundleNamespace,
116) -> EngineResult<Value> {
117 let base_uri = directory_file_uri(base_dir)?;
118 let retriever = FsHttpRetrieve::new(fetch_policy, load_budget);
119 bundle_with_retriever_in_namespace(schema, &base_uri, retriever, namespace)
120}
121
122#[instrument(skip_all)]
134pub fn bundle_prepared_refs(schema: Value, base_dir: &Path) -> EngineResult<Value> {
135 let base_uri = directory_file_uri(base_dir)?;
136 bundle_with_retriever(schema, &base_uri, NoExternalRetrieve)
137}
138
139#[instrument(skip_all)]
150pub fn flatten_with_retriever(
151 schema: &Value,
152 base_uri: &str,
153 retriever: impl Retrieve + 'static,
154) -> EngineResult<Value> {
155 let dereferenced = jsonschema::options()
156 .with_base_uri(base_uri.to_string())
157 .with_retriever(retriever)
158 .dereference(schema)?;
159 Ok(dereferenced)
160}
161
162#[instrument(skip_all)]
174pub fn bundle_with_retriever(
175 schema: Value,
176 base_uri: &str,
177 retriever: impl Retrieve,
178) -> EngineResult<Value> {
179 let mut namespace = BundleNamespace::default();
180 bundle_with_retriever_in_namespace(schema, base_uri, retriever, &mut namespace)
181}
182
183fn bundle_with_retriever_in_namespace(
184 mut schema: Value,
185 base_uri: &str,
186 retriever: impl Retrieve,
187 namespace: &mut BundleNamespace,
188) -> EngineResult<Value> {
189 namespace.reserve_schema(&schema);
190 let root_document_uri = document_uri(&uri::from_str(base_uri)?)?;
191 let root_base_uri = effective_base_uri(&schema, &root_document_uri)?;
192 let root_document_uris = BTreeSet::from([
193 root_document_uri.as_str().to_string(),
194 root_base_uri.as_str().to_string(),
195 ]);
196 let mut state = BundleState::new(retriever, root_document_uris, namespace);
197 state.bundle_schema(&mut schema, &root_document_uri)?;
198 state.insert_definitions(&mut schema)?;
199 Ok(schema)
200}
201
202#[derive(Default)]
204pub(crate) struct BundleNamespace {
205 definition_names: BTreeSet<String>,
206 names_by_target_uri: BTreeMap<String, String>,
207 next_definition_id: usize,
208}
209
210impl BundleNamespace {
211 pub(crate) fn reserve_schema(&mut self, schema: &Value) {
212 self.definition_names
216 .extend(existing_definition_names(schema));
217 }
218
219 fn next_definition_name(&mut self) -> String {
220 loop {
221 self.next_definition_id += 1;
222 let name = format!("schema{}", self.next_definition_id);
223 if self.definition_names.insert(name.clone()) {
224 return name;
225 }
226 }
227 }
228}
229
230struct BundleState<'a, R> {
231 retriever: R,
232 root_document_uris: BTreeSet<String>,
233 definitions: BTreeMap<String, Value>,
234 namespace: &'a mut BundleNamespace,
235}
236
237impl<'a, R: Retrieve> BundleState<'a, R> {
238 fn new(
239 retriever: R,
240 root_document_uris: BTreeSet<String>,
241 namespace: &'a mut BundleNamespace,
242 ) -> Self {
243 Self {
244 retriever,
245 root_document_uris,
246 definitions: BTreeMap::new(),
247 namespace,
248 }
249 }
250
251 fn bundle_schema(
252 &mut self,
253 schema: &mut Value,
254 current_document_uri: &Uri<String>,
255 ) -> EngineResult<()> {
256 let current_document_uri = effective_base_uri(schema, current_document_uri)?;
257 if let Some(reference) = schema_reference(schema) {
258 let target_uri = uri::resolve_against(¤t_document_uri.borrow(), &reference)?;
259 if self.should_preserve_reference(&target_uri, ¤t_document_uri)? {
260 return Ok(());
261 }
262 let definition_name = self.definition_name_for_target(&target_uri)?;
263 *schema = definition_ref(&definition_name);
264 return Ok(());
265 }
266
267 try_visit_subschemas_mut(schema, &mut |subschema| {
268 self.bundle_schema(subschema, ¤t_document_uri)
269 })
270 }
271
272 fn should_preserve_reference(
273 &self,
274 target_uri: &Uri<String>,
275 current_document_uri: &Uri<String>,
276 ) -> EngineResult<bool> {
277 let target_document_uri = document_uri(target_uri)?;
278 Ok(self.is_root_document(&target_document_uri)
279 && self.is_root_document(current_document_uri))
280 }
281
282 fn definition_name_for_target(&mut self, target_uri: &Uri<String>) -> EngineResult<String> {
283 let target_key = target_uri.as_str().to_string();
284 if let Some(name) = self.namespace.names_by_target_uri.get(&target_key) {
285 return Ok(name.clone());
286 }
287
288 let name = self.namespace.next_definition_name();
289 self.namespace
290 .names_by_target_uri
291 .insert(target_key, name.clone());
292
293 let target_document_uri = document_uri(target_uri)?;
294 let mut target_schema = self.resolve_target_schema(target_uri, &target_document_uri)?;
295 self.bundle_schema(&mut target_schema, &target_document_uri)?;
296 self.definitions.insert(name.clone(), target_schema);
297
298 Ok(name)
299 }
300
301 fn resolve_target_schema(
302 &self,
303 target_uri: &Uri<String>,
304 target_document_uri: &Uri<String>,
305 ) -> EngineResult<Value> {
306 if self.is_root_document(target_document_uri) {
307 return Err(CliError::RefBundling(format!(
308 "cannot bundle non-local ref back to root document: {target_uri}"
309 )));
310 }
311
312 let document = self
313 .retriever
314 .retrieve(target_document_uri)
315 .map_err(|err| {
316 CliError::RefBundling(format!("retrieve {target_document_uri}: {err}"))
317 })?;
318 select_fragment(document, target_uri)
319 }
320
321 fn insert_definitions(self, schema: &mut Value) -> EngineResult<()> {
322 if self.definitions.is_empty() {
323 return Ok(());
324 }
325
326 let Value::Object(root) = schema else {
327 return Err(CliError::RefBundling(
328 "cannot insert bundled definitions into non-object root schema".to_string(),
329 ));
330 };
331 let entry = root
332 .entry("$defs".to_string())
333 .or_insert_with(|| Value::Object(Map::new()));
334 let Value::Object(existing) = entry else {
335 return Err(CliError::RefBundling(
336 "cannot insert bundled definitions because root $defs is not an object".to_string(),
337 ));
338 };
339 for (name, definition) in self.definitions {
340 existing.insert(name, definition);
341 }
342 Ok(())
343 }
344
345 fn is_root_document(&self, document_uri: &Uri<String>) -> bool {
346 self.root_document_uris.contains(document_uri.as_str())
347 }
348}
349
350struct FsHttpRetrieve {
354 fetch_policy: FetchPolicy,
355 load_budget: LoadBudget,
356 agent: ureq::Agent,
357}
358
359impl FsHttpRetrieve {
360 fn new(fetch_policy: FetchPolicy, load_budget: LoadBudget) -> Self {
361 Self {
362 fetch_policy,
363 load_budget,
364 agent: ureq::Agent::new_with_defaults(),
365 }
366 }
367}
368
369impl Retrieve for FsHttpRetrieve {
370 fn retrieve(
371 &self,
372 uri: &Uri<String>,
373 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
374 let scheme = uri.scheme().as_str().to_ascii_lowercase();
375 match scheme.as_str() {
376 "file" => {
377 let host = uri
378 .authority()
379 .map(|authority| authority.host())
380 .unwrap_or("");
381 self.fetch_policy
382 .validate_file_host(host)
383 .map_err(|err| format!("$ref to {uri} but {err}"))?;
384 let path = file_uri_path(uri)?;
385 let mut file = std::fs::File::open(&path)
386 .map_err(|error| format!("open {}: {error}", path.display()))?;
387 let bytes = read_to_end_capped(
388 &mut file,
389 self.load_budget.max_schema_document_bytes,
390 path.display().to_string(),
391 )
392 .map_err(|e| e.to_string())?;
393 let value: Value = serde_json::from_slice(&bytes)
394 .map_err(|error| format!("parse {}: {error}", path.display()))?;
395 Ok(value)
396 }
397 "http" | "https" => {
398 let host = uri.authority().map(|authority| authority.host());
399 self.fetch_policy
400 .validate_network_host(host)
401 .map_err(|err| format!("$ref to {uri} but {err}"))?;
402 let resp = self
403 .agent
404 .get(uri.as_str())
405 .call()
406 .map_err(|e| format!("fetch {uri}: {e}"))?;
407 let mut body = resp.into_body();
408 let mut reader = body.as_reader();
409 let bytes = read_to_end_capped(
410 &mut reader,
411 self.load_budget.max_schema_document_bytes,
412 uri.as_str().to_string(),
413 )
414 .map_err(|e| e.to_string())?;
415 let value: Value =
416 serde_json::from_slice(&bytes).map_err(|e| format!("parse {uri}: {e}"))?;
417 Ok(value)
418 }
419 other => Err(format!("unsupported $ref scheme: {other} (uri={uri})").into()),
420 }
421 }
422}
423
424struct NoExternalRetrieve;
425
426impl Retrieve for NoExternalRetrieve {
427 fn retrieve(
428 &self,
429 uri: &Uri<String>,
430 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
431 Err(format!("external $ref remained after input preparation: {uri}").into())
432 }
433}
434
435fn schema_reference(schema: &Value) -> Option<String> {
436 let Value::Object(object) = schema else {
437 return None;
438 };
439 object
440 .get("$ref")
441 .and_then(Value::as_str)
442 .map(str::to_string)
443}
444
445fn document_uri(uri: &Uri<String>) -> EngineResult<Uri<String>> {
446 let document = uri.strip_fragment().as_str().to_string();
447 Uri::parse(document)
448 .map_err(|err| CliError::RefBundling(format!("parse document uri for {uri}: {err:?}")))
449}
450
451fn effective_base_uri(
452 schema: &Value,
453 current_document_uri: &Uri<String>,
454) -> EngineResult<Uri<String>> {
455 let Some(id) = schema
456 .as_object()
457 .and_then(|object| object.get("$id"))
458 .and_then(Value::as_str)
459 else {
460 return Ok(current_document_uri.clone());
461 };
462
463 let resolved = uri::resolve_against(¤t_document_uri.borrow(), id)?;
464 document_uri(&resolved)
465}
466
467fn select_fragment(document: Value, target_uri: &Uri<String>) -> EngineResult<Value> {
468 let Some(fragment) = target_uri.fragment() else {
469 return Ok(document);
470 };
471 let pointer = fragment.decode().to_string().map_err(|_| {
472 CliError::RefBundling(format!("decode json pointer fragment for {target_uri}"))
473 })?;
474 if pointer.is_empty() {
475 return Ok(document);
476 }
477 if !pointer.starts_with('/') {
478 return Err(CliError::RefBundling(format!(
479 "unsupported non-json-pointer fragment in {target_uri}"
480 )));
481 }
482
483 document.pointer(&pointer).cloned().ok_or_else(|| {
484 CliError::RefBundling(format!("json pointer {pointer} not found in {target_uri}"))
485 })
486}
487
488fn existing_definition_names(schema: &Value) -> BTreeSet<String> {
489 schema
490 .get("$defs")
491 .and_then(Value::as_object)
492 .map(|definitions| definitions.keys().cloned().collect())
493 .unwrap_or_default()
494}
495
496fn definition_ref(name: &str) -> Value {
497 Value::Object(Map::from_iter([(
498 "$ref".to_string(),
499 Value::String(format!("#/$defs/{name}")),
500 )]))
501}
502
503fn directory_file_uri(path: &Path) -> EngineResult<String> {
504 let path = if path.as_os_str().is_empty() {
505 Path::new(".")
506 } else {
507 path
508 };
509 let absolute = path.canonicalize().or_else(|_| std::path::absolute(path))?;
510 let uri = Url::from_directory_path(&absolute)
511 .map_err(|()| CliError::InvalidFileUriPath { path: absolute })?;
512 Ok(uri.into())
513}
514
515fn file_uri_path(uri: &Uri<String>) -> EngineResult<PathBuf> {
516 let invalid_uri = || CliError::InvalidFileUri {
517 uri: uri.as_str().to_string(),
518 };
519 Url::parse(uri.as_str())
520 .map_err(|_| invalid_uri())?
521 .to_file_path()
522 .map_err(|()| invalid_uri())
523}
524
525#[cfg(test)]
526#[path = "tests/flatten.rs"]
527mod tests;