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 base_uri = directory_file_uri(base_dir)?;
106 let retriever = FsHttpRetrieve::new(fetch_policy, load_budget);
107 bundle_with_retriever(schema, &base_uri, retriever)
108}
109
110#[instrument(skip_all)]
122pub fn bundle_prepared_refs(schema: Value, base_dir: &Path) -> EngineResult<Value> {
123 let base_uri = directory_file_uri(base_dir)?;
124 bundle_with_retriever(schema, &base_uri, NoExternalRetrieve)
125}
126
127#[instrument(skip_all)]
138pub fn flatten_with_retriever(
139 schema: &Value,
140 base_uri: &str,
141 retriever: impl Retrieve + 'static,
142) -> EngineResult<Value> {
143 let dereferenced = jsonschema::options()
144 .with_base_uri(base_uri.to_string())
145 .with_retriever(retriever)
146 .dereference(schema)?;
147 Ok(dereferenced)
148}
149
150#[instrument(skip_all)]
162pub fn bundle_with_retriever(
163 mut schema: Value,
164 base_uri: &str,
165 retriever: impl Retrieve,
166) -> EngineResult<Value> {
167 let root_document_uri = document_uri(&uri::from_str(base_uri)?)?;
168 let root_base_uri = effective_base_uri(&schema, &root_document_uri)?;
169 let root_document_uris = BTreeSet::from([
170 root_document_uri.as_str().to_string(),
171 root_base_uri.as_str().to_string(),
172 ]);
173 let existing_definition_names = existing_definition_names(&schema);
174 let mut state = BundleState::new(retriever, root_document_uris, existing_definition_names);
175 state.bundle_schema(&mut schema, &root_document_uri)?;
176 state.insert_definitions(&mut schema)?;
177 Ok(schema)
178}
179
180struct BundleState<R> {
181 retriever: R,
182 root_document_uris: BTreeSet<String>,
183 names_by_target_uri: BTreeMap<String, String>,
184 definitions: BTreeMap<String, Value>,
185 existing_definition_names: BTreeSet<String>,
186 next_definition_id: usize,
187}
188
189impl<R: Retrieve> BundleState<R> {
190 fn new(
191 retriever: R,
192 root_document_uris: BTreeSet<String>,
193 existing_definition_names: BTreeSet<String>,
194 ) -> Self {
195 Self {
196 retriever,
197 root_document_uris,
198 names_by_target_uri: BTreeMap::new(),
199 definitions: BTreeMap::new(),
200 existing_definition_names,
201 next_definition_id: 1,
202 }
203 }
204
205 fn bundle_schema(
206 &mut self,
207 schema: &mut Value,
208 current_document_uri: &Uri<String>,
209 ) -> EngineResult<()> {
210 let current_document_uri = effective_base_uri(schema, current_document_uri)?;
211 if let Some(reference) = schema_reference(schema) {
212 let target_uri = uri::resolve_against(¤t_document_uri.borrow(), &reference)?;
213 if self.should_preserve_reference(&target_uri, ¤t_document_uri)? {
214 return Ok(());
215 }
216 let definition_name = self.definition_name_for_target(&target_uri)?;
217 *schema = definition_ref(&definition_name);
218 return Ok(());
219 }
220
221 try_visit_subschemas_mut(schema, &mut |subschema| {
222 self.bundle_schema(subschema, ¤t_document_uri)
223 })
224 }
225
226 fn should_preserve_reference(
227 &self,
228 target_uri: &Uri<String>,
229 current_document_uri: &Uri<String>,
230 ) -> EngineResult<bool> {
231 let target_document_uri = document_uri(target_uri)?;
232 Ok(self.is_root_document(&target_document_uri)
233 && self.is_root_document(current_document_uri))
234 }
235
236 fn definition_name_for_target(&mut self, target_uri: &Uri<String>) -> EngineResult<String> {
237 let target_key = target_uri.as_str().to_string();
238 if let Some(name) = self.names_by_target_uri.get(&target_key) {
239 return Ok(name.clone());
240 }
241
242 let name = self.next_definition_name();
243 self.names_by_target_uri.insert(target_key, name.clone());
244
245 let target_document_uri = document_uri(target_uri)?;
246 let mut target_schema = self.resolve_target_schema(target_uri, &target_document_uri)?;
247 self.bundle_schema(&mut target_schema, &target_document_uri)?;
248 self.definitions.insert(name.clone(), target_schema);
249
250 Ok(name)
251 }
252
253 fn resolve_target_schema(
254 &self,
255 target_uri: &Uri<String>,
256 target_document_uri: &Uri<String>,
257 ) -> EngineResult<Value> {
258 if self.is_root_document(target_document_uri) {
259 return Err(CliError::RefBundling(format!(
260 "cannot bundle non-local ref back to root document: {target_uri}"
261 )));
262 }
263
264 let document = self
265 .retriever
266 .retrieve(target_document_uri)
267 .map_err(|err| {
268 CliError::RefBundling(format!("retrieve {target_document_uri}: {err}"))
269 })?;
270 select_fragment(document, target_uri)
271 }
272
273 fn next_definition_name(&mut self) -> String {
274 loop {
275 let name = format!("schema{}", self.next_definition_id);
276 self.next_definition_id += 1;
277 if self.existing_definition_names.insert(name.clone()) {
278 return name;
279 }
280 }
281 }
282
283 fn insert_definitions(self, schema: &mut Value) -> EngineResult<()> {
284 if self.definitions.is_empty() {
285 return Ok(());
286 }
287
288 let Value::Object(root) = schema else {
289 return Err(CliError::RefBundling(
290 "cannot insert bundled definitions into non-object root schema".to_string(),
291 ));
292 };
293 let entry = root
294 .entry("$defs".to_string())
295 .or_insert_with(|| Value::Object(Map::new()));
296 let Value::Object(existing) = entry else {
297 return Err(CliError::RefBundling(
298 "cannot insert bundled definitions because root $defs is not an object".to_string(),
299 ));
300 };
301 for (name, definition) in self.definitions {
302 existing.insert(name, definition);
303 }
304 Ok(())
305 }
306
307 fn is_root_document(&self, document_uri: &Uri<String>) -> bool {
308 self.root_document_uris.contains(document_uri.as_str())
309 }
310}
311
312struct FsHttpRetrieve {
316 fetch_policy: FetchPolicy,
317 load_budget: LoadBudget,
318 agent: ureq::Agent,
319}
320
321impl FsHttpRetrieve {
322 fn new(fetch_policy: FetchPolicy, load_budget: LoadBudget) -> Self {
323 Self {
324 fetch_policy,
325 load_budget,
326 agent: ureq::Agent::new_with_defaults(),
327 }
328 }
329}
330
331impl Retrieve for FsHttpRetrieve {
332 fn retrieve(
333 &self,
334 uri: &Uri<String>,
335 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
336 let scheme = uri.scheme().as_str().to_ascii_lowercase();
337 match scheme.as_str() {
338 "file" => {
339 let host = uri
340 .authority()
341 .map(|authority| authority.host())
342 .unwrap_or("");
343 self.fetch_policy
344 .validate_file_host(host)
345 .map_err(|err| format!("$ref to {uri} but {err}"))?;
346 let path = file_uri_path(uri)?;
347 let mut file = std::fs::File::open(&path)
348 .map_err(|error| format!("open {}: {error}", path.display()))?;
349 let bytes = read_to_end_capped(
350 &mut file,
351 self.load_budget.max_schema_document_bytes,
352 path.display().to_string(),
353 )
354 .map_err(|e| e.to_string())?;
355 let value: Value = serde_json::from_slice(&bytes)
356 .map_err(|error| format!("parse {}: {error}", path.display()))?;
357 Ok(value)
358 }
359 "http" | "https" => {
360 let host = uri.authority().map(|authority| authority.host());
361 self.fetch_policy
362 .validate_network_host(host)
363 .map_err(|err| format!("$ref to {uri} but {err}"))?;
364 let resp = self
365 .agent
366 .get(uri.as_str())
367 .call()
368 .map_err(|e| format!("fetch {uri}: {e}"))?;
369 let mut body = resp.into_body();
370 let mut reader = body.as_reader();
371 let bytes = read_to_end_capped(
372 &mut reader,
373 self.load_budget.max_schema_document_bytes,
374 uri.as_str().to_string(),
375 )
376 .map_err(|e| e.to_string())?;
377 let value: Value =
378 serde_json::from_slice(&bytes).map_err(|e| format!("parse {uri}: {e}"))?;
379 Ok(value)
380 }
381 other => Err(format!("unsupported $ref scheme: {other} (uri={uri})").into()),
382 }
383 }
384}
385
386struct NoExternalRetrieve;
387
388impl Retrieve for NoExternalRetrieve {
389 fn retrieve(
390 &self,
391 uri: &Uri<String>,
392 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
393 Err(format!("external $ref remained after input preparation: {uri}").into())
394 }
395}
396
397fn schema_reference(schema: &Value) -> Option<String> {
398 let Value::Object(object) = schema else {
399 return None;
400 };
401 object
402 .get("$ref")
403 .and_then(Value::as_str)
404 .map(str::to_string)
405}
406
407fn document_uri(uri: &Uri<String>) -> EngineResult<Uri<String>> {
408 let document = uri.strip_fragment().as_str().to_string();
409 Uri::parse(document)
410 .map_err(|err| CliError::RefBundling(format!("parse document uri for {uri}: {err:?}")))
411}
412
413fn effective_base_uri(
414 schema: &Value,
415 current_document_uri: &Uri<String>,
416) -> EngineResult<Uri<String>> {
417 let Some(id) = schema
418 .as_object()
419 .and_then(|object| object.get("$id"))
420 .and_then(Value::as_str)
421 else {
422 return Ok(current_document_uri.clone());
423 };
424
425 let resolved = uri::resolve_against(¤t_document_uri.borrow(), id)?;
426 document_uri(&resolved)
427}
428
429fn select_fragment(document: Value, target_uri: &Uri<String>) -> EngineResult<Value> {
430 let Some(fragment) = target_uri.fragment() else {
431 return Ok(document);
432 };
433 let pointer = fragment.decode().to_string().map_err(|_| {
434 CliError::RefBundling(format!("decode json pointer fragment for {target_uri}"))
435 })?;
436 if pointer.is_empty() {
437 return Ok(document);
438 }
439 if !pointer.starts_with('/') {
440 return Err(CliError::RefBundling(format!(
441 "unsupported non-json-pointer fragment in {target_uri}"
442 )));
443 }
444
445 document.pointer(&pointer).cloned().ok_or_else(|| {
446 CliError::RefBundling(format!("json pointer {pointer} not found in {target_uri}"))
447 })
448}
449
450fn existing_definition_names(schema: &Value) -> BTreeSet<String> {
451 schema
452 .get("$defs")
453 .and_then(Value::as_object)
454 .map(|definitions| definitions.keys().cloned().collect())
455 .unwrap_or_default()
456}
457
458fn definition_ref(name: &str) -> Value {
459 Value::Object(Map::from_iter([(
460 "$ref".to_string(),
461 Value::String(format!("#/$defs/{name}")),
462 )]))
463}
464
465fn directory_file_uri(path: &Path) -> EngineResult<String> {
466 let path = if path.as_os_str().is_empty() {
467 Path::new(".")
468 } else {
469 path
470 };
471 let absolute = path.canonicalize().or_else(|_| std::path::absolute(path))?;
472 let uri = Url::from_directory_path(&absolute)
473 .map_err(|()| CliError::InvalidFileUriPath { path: absolute })?;
474 Ok(uri.into())
475}
476
477fn file_uri_path(uri: &Uri<String>) -> EngineResult<PathBuf> {
478 let invalid_uri = || CliError::InvalidFileUri {
479 uri: uri.as_str().to_string(),
480 };
481 Url::parse(uri.as_str())
482 .map_err(|_| invalid_uri())?
483 .to_file_path()
484 .map_err(|()| invalid_uri())
485}
486
487#[cfg(test)]
488#[path = "tests/flatten.rs"]
489mod tests;