LCOV - code coverage report
Current view: top level - pkg/lib - import-json.ts Coverage Total Hit
Test: cockpit Lines: 76.3 % 93 71
Test Date: 2026-06-17 06:28:00

            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          123 : class ValidationError extends Error {
      79              :     msg: string;
      80              :     path: string;
      81            0 :     constructor(msg?: string, parent?: ValidationError, path?: string) {
      82            0 :         let this_msg = "";
      83            0 :         let this_path = "";
      84            0 :         if (msg) {
      85            0 :             this_msg = msg;
      86            0 :             this_path = "";
      87            0 :         } else if (parent && path) {
      88            0 :             this_msg = parent.msg;
      89            0 :             this_path = path + parent.path;
      90            0 :         }
      91            0 :         super(`JSON validation error for ${this_path}: ${this_msg}`);
      92            0 :         this.msg = this_msg;
      93            0 :         this.path = this_path;
      94            0 :     }
      95          123 : }
      96              : 
      97          123 : function with_validation_path<T>(p: string, func: () => T): T {
      98          123 :     try {
      99          123 :         return func();
     100           19 :     } catch (e) {
     101           19 :         if (e instanceof ValidationError)
     102           19 :             throw new ValidationError(undefined, e, p);
     103              :         else
     104           19 :             throw e;
     105           19 :     }
     106          123 : }
     107              : 
     108            0 : function validation_error(msg: string): never {
     109            0 :     throw new ValidationError(msg);
     110            0 : }
     111              : 
     112          123 : export function import_string(val: JsonValue): string {
     113          123 :     if (typeof val == "string")
     114          123 :         return val;
     115           19 :     validation_error(`Not a string: ${JSON.stringify(val)}`);
     116          123 : }
     117              : 
     118          123 : export function import_number(val: JsonValue): number {
     119          123 :     if (typeof val == "number")
     120          123 :         return val;
     121           19 :     validation_error(`Not a number: ${JSON.stringify(val)}`);
     122          123 : }
     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          123 : function is_json_object(val: JsonValue): val is JsonObject {
     131          123 :     return !!val && typeof val == "object" && !Array.isArray(val);
     132          123 : }
     133              : 
     134          123 : export function import_json_object(val: JsonValue): JsonObject {
     135          123 :     if (is_json_object(val))
     136          123 :         return val;
     137           19 :     validation_error(`Not an object: ${JSON.stringify(val)}`);
     138          123 : }
     139              : 
     140          123 : function is_json_array(val: JsonValue): val is JsonValue[] {
     141          123 :     return Array.isArray(val);
     142          123 : }
     143              : 
     144          123 : export function import_json_array(val: JsonValue): JsonValue[] {
     145          123 :     if (is_json_array(val))
     146          123 :         return val;
     147           19 :     validation_error(`Not an array: ${JSON.stringify(val)}`);
     148          123 : }
     149              : 
     150          123 : export function import_record<T>(val: JsonValue, importer: (val: JsonValue) => T): Record<string, T> {
     151          123 :     const obj = import_json_object(val);
     152          123 :     return Object.fromEntries(Object.entries(obj).map(
     153          123 :         ([k, v]) => [k, with_validation_path(`.${k}`, () => importer(v))]));
     154          123 : }
     155              : 
     156          123 : export function import_array<T>(val: JsonValue, importer: (val: JsonValue) => T): Array<T> {
     157          123 :     const arr = import_json_array(val);
     158          123 :     return arr.map((elt, i) => with_validation_path(`[${i}]`, () => importer(elt)));
     159          123 : }
     160              : 
     161          123 : export function get<T>(obj: JsonObject, field: string, importer: (val: JsonValue) => T, fallback?: T): T {
     162          123 :     if (field in obj)
     163          123 :         return with_validation_path(`.${String(field)}`, () => importer(obj[field]));
     164           19 :     else if (fallback !== undefined)
     165           19 :         return fallback;
     166              :     else
     167           19 :         validation_error(`Field "${String(field)}" is missing`);
     168          123 : }
     169              : 
     170          123 : export function get_optional<T>(obj: JsonObject, field: string, importer: (val: JsonValue) => T): T | undefined {
     171          123 :     if (field in obj)
     172          123 :         return with_validation_path(`.${String(field)}`, () => importer(obj[field]));
     173          123 :     return undefined;
     174          123 : }
     175              : 
     176          123 : export function validate<T>(path: string, val: JsonValue | undefined, importer: (val: JsonValue) => T, fallback: T): T {
     177          123 :     if (val === undefined)
     178           19 :         return fallback;
     179              : 
     180          123 :     try {
     181          123 :         return with_validation_path(path, () => importer(val));
     182           19 :     } 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           19 :         if (e instanceof ValidationError) {
     187           19 :             console.error(e.message);
     188           19 :             return fallback;
     189           19 :         } else
     190           19 :             throw e;
     191           19 :     }
     192          123 : }
        

Generated by: LCOV version 2.0-1