LCOV - code coverage report
Current view: top level - pkg/lib - import-json.ts Coverage Total Hit
Test: cockpit Lines: 94.6 % 93 88
Test Date: 2026-07-17 12:03:54

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2024 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6              : import { JsonValue, JsonObject } from "cockpit";
       7              : 
       8              : /* GENERIC VALIDATION MACHINERY
       9              : 
      10              :    This module helps with turning arbitrary user provided JSON blobs
      11              :    into well-typed objects.
      12              : 
      13              :    The basic idea is that for a TypeScript interface "Foo" you will
      14              :    write a importer function with this signature:
      15              : 
      16              :       function import_Foo(val: JsonValue): Foo;
      17              : 
      18              :    This function will either return a valid Foo, or throw a
      19              :    ValidationError.
      20              : 
      21              :    When needing to convert a JSON blob into a Foo, you can call this
      22              :    function directly. You might need to catch the potential
      23              :    ValidationError.
      24              : 
      25              :    Alternatively, you can also use the "validate" wrapper like so
      26              : 
      27              :       const foo = validate("config.foo", config.foo, import_Foo, {});
      28              : 
      29              :    This will include "config.foo" in the error messages to give a
      30              :    better clue where the invalid data is actually coming from, and
      31              :    will catch the ValidationError and return a fallback value.
      32              : 
      33              :    Validation is strict: If a validation error occurs deep inside a
      34              :    nested structure, the whole structure is rejected.
      35              :  */
      36              : 
      37              : /* WRITING IMPORTER FUNCTIONS
      38              : 
      39              :    The process of writing a importer function for a given TypeScript
      40              :    interface is pretty mechanic, and could well be automated.
      41              : 
      42              :    For example, here are the functions for Player and Team interfaces:
      43              : 
      44              :        interface Player {
      45              :           name: string;
      46              :           age: number | undefined;
      47              :        }
      48              : 
      49              :        function import_Player(val: JsonValue): Player {
      50              :            const obj = import_json_object(val);
      51              :            return {
      52              :                name: get(obj, "name", import_string),
      53              :                age: get_optional(obj, "age", import_number),
      54              :            };
      55              :        }
      56              : 
      57              :        interface Team {
      58              :           name: string;
      59              :           players: Player[];
      60              :        }
      61              : 
      62              :        function import_Team(val: JsonValue): Team {
      63              :            const obj = import_json_object(val);
      64              :            return {
      65              :                name: get(obj, "name", import_string),
      66              :                players: get(obj, "players", v => import_array(v, import_Player)),
      67              :            }
      68              :        }
      69              : 
      70              :    This way, TypeScript will check that the returned values are indeed
      71              :    valid for their type declaration. You can't get that wrong. What is
      72              :    not checked is that you use the right field names when accessing
      73              :    input JsonObjects. But we could write a linter function for that.
      74              : 
      75              :    More examples can be found in "pkg/shell/manifests.ts".
      76              :  */
      77              : 
      78          341 : class ValidationError extends Error {
      79              :     msg: string;
      80              :     path: string;
      81            1 :     constructor(msg?: string, parent?: ValidationError, path?: string) {
      82            1 :         let this_msg = "";
      83            1 :         let this_path = "";
      84            1 :         if (msg) {
      85            1 :             this_msg = msg;
      86            1 :             this_path = "";
      87            1 :         } else if (parent && path) {
      88            1 :             this_msg = parent.msg;
      89            1 :             this_path = path + parent.path;
      90            1 :         }
      91            1 :         super(`JSON validation error for ${this_path}: ${this_msg}`);
      92            1 :         this.msg = this_msg;
      93            1 :         this.path = this_path;
      94            1 :     }
      95          341 : }
      96              : 
      97          341 : function with_validation_path<T>(p: string, func: () => T): T {
      98          341 :     try {
      99          341 :         return func();
     100           63 :     } catch (e) {
     101           63 :         if (e instanceof ValidationError)
     102           63 :             throw new ValidationError(undefined, e, p);
     103              :         else
     104           63 :             throw e;
     105           63 :     }
     106          341 : }
     107              : 
     108            1 : function validation_error(msg: string): never {
     109            1 :     throw new ValidationError(msg);
     110            1 : }
     111              : 
     112          341 : export function import_string(val: JsonValue): string {
     113          341 :     if (typeof val == "string")
     114          341 :         return val;
     115           63 :     validation_error(`Not a string: ${JSON.stringify(val)}`);
     116          341 : }
     117              : 
     118          341 : export function import_number(val: JsonValue): number {
     119          341 :     if (typeof val == "number")
     120          341 :         return val;
     121           63 :     validation_error(`Not a number: ${JSON.stringify(val)}`);
     122          341 : }
     123              : 
     124            0 : export function import_boolean(val: JsonValue): boolean {
     125            0 :     if (typeof val == "boolean")
     126            0 :         return val;
     127            0 :     validation_error(`Not a boolean: ${JSON.stringify(val)}`);
     128            0 : }
     129              : 
     130          341 : function is_json_object(val: JsonValue): val is JsonObject {
     131          341 :     return !!val && typeof val == "object" && !Array.isArray(val);
     132          341 : }
     133              : 
     134          341 : export function import_json_object(val: JsonValue): JsonObject {
     135          341 :     if (is_json_object(val))
     136          341 :         return val;
     137           63 :     validation_error(`Not an object: ${JSON.stringify(val)}`);
     138          341 : }
     139              : 
     140          341 : function is_json_array(val: JsonValue): val is JsonValue[] {
     141          341 :     return Array.isArray(val);
     142          341 : }
     143              : 
     144          341 : export function import_json_array(val: JsonValue): JsonValue[] {
     145          341 :     if (is_json_array(val))
     146          341 :         return val;
     147           63 :     validation_error(`Not an array: ${JSON.stringify(val)}`);
     148          341 : }
     149              : 
     150          341 : export function import_record<T>(val: JsonValue, importer: (val: JsonValue) => T): Record<string, T> {
     151          341 :     const obj = import_json_object(val);
     152          341 :     return Object.fromEntries(Object.entries(obj).map(
     153          341 :         ([k, v]) => [k, with_validation_path(`.${k}`, () => importer(v))]));
     154          341 : }
     155              : 
     156          341 : export function import_array<T>(val: JsonValue, importer: (val: JsonValue) => T): Array<T> {
     157          341 :     const arr = import_json_array(val);
     158          341 :     return arr.map((elt, i) => with_validation_path(`[${i}]`, () => importer(elt)));
     159          341 : }
     160              : 
     161          341 : export function get<T>(obj: JsonObject, field: string, importer: (val: JsonValue) => T, fallback?: T): T {
     162          341 :     if (field in obj)
     163          341 :         return with_validation_path(`.${String(field)}`, () => importer(obj[field]));
     164           63 :     else if (fallback !== undefined)
     165           63 :         return fallback;
     166              :     else
     167           63 :         validation_error(`Field "${String(field)}" is missing`);
     168          341 : }
     169              : 
     170          341 : export function get_optional<T>(obj: JsonObject, field: string, importer: (val: JsonValue) => T): T | undefined {
     171          341 :     if (field in obj)
     172          341 :         return with_validation_path(`.${String(field)}`, () => importer(obj[field]));
     173          341 :     return undefined;
     174          341 : }
     175              : 
     176          341 : export function validate<T>(path: string, val: JsonValue | undefined, importer: (val: JsonValue) => T, fallback: T): T {
     177          341 :     if (val === undefined)
     178           63 :         return fallback;
     179              : 
     180          341 :     try {
     181          341 :         return with_validation_path(path, () => importer(val));
     182           63 :     } catch (e) {
     183              :         // When the input is invalid, we report this and return the
     184              :         // fallback. All other errors, like programming errors in the
     185              :         // importer, are passed on.
     186           63 :         if (e instanceof ValidationError) {
     187           63 :             console.error(e.message);
     188           63 :             return fallback;
     189           63 :         } else
     190           63 :             throw e;
     191           63 :     }
     192          341 : }
        

Generated by: LCOV version 2.0-1