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 : }
|