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