Skip to content

Commit 1cd689a

Browse files
committed
Validation implementation
more light-weight than using a fully fledged library like avj, and most likely more than sufficient to handle edge cases.
1 parent 176afc8 commit 1cd689a

File tree

3 files changed

+210
-4
lines changed

3 files changed

+210
-4
lines changed

src/hlsBinaries.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import * as path from 'path';
66
import { promisify } from 'util';
77
import { env, ExtensionContext, ProgressLocation, Uri, window, WorkspaceFolder } from 'vscode';
88
import { downloadFile, executableExists, httpsGetSilently } from './utils';
9+
import * as validate from './validation';
910

1011
/** GitHub API release */
1112
interface IRelease {
12-
assets: [IAsset];
13+
assets: IAsset[];
1314
tag_name: string;
1415
prerelease: boolean;
1516
}
@@ -19,6 +20,21 @@ interface IAsset {
1920
name: string;
2021
}
2122

23+
const assetValidator: validate.Validator<IAsset> = validate.object({
24+
browser_download_url: validate.string(),
25+
name: validate.string(),
26+
});
27+
28+
const releaseValidator: validate.Validator<IRelease> = validate.object({
29+
assets: validate.array(assetValidator),
30+
tag_name: validate.string(),
31+
prerelease: validate.boolean(),
32+
});
33+
34+
const githubReleaseApiValidator: validate.Validator<IRelease[]> = validate.array(releaseValidator);
35+
36+
const cachedReleaseValidator: validate.Validator<IRelease | null> = validate.optional(releaseValidator);
37+
2238
// On Windows the executable needs to be stored somewhere with an .exe extension
2339
const exeExt = process.platform === 'win32' ? '.exe' : '';
2440

@@ -141,7 +157,7 @@ async function getProjectGhcVersion(context: ExtensionContext, dir: string, rele
141157
return callWrapper(downloadedWrapper);
142158
}
143159

144-
async function getLatestReleaseMetadata(context: ExtensionContext): Promise<IRelease | undefined> {
160+
async function getLatestReleaseMetadata(context: ExtensionContext): Promise<IRelease | null> {
145161
const opts: https.RequestOptions = {
146162
host: 'api.github.com',
147163
path: '/repos/haskell/haskell-language-server/releases',
@@ -150,7 +166,8 @@ async function getLatestReleaseMetadata(context: ExtensionContext): Promise<IRel
150166

151167
try {
152168
const releaseInfo = await httpsGetSilently(opts);
153-
const latestInfoParsed = (JSON.parse(releaseInfo) as IRelease[]).find((x) => !x.prerelease);
169+
const latestInfoParsed =
170+
validate.parseAndValidate(releaseInfo, githubReleaseApiValidator).find((x) => !x.prerelease) || null;
154171

155172
// Cache the latest successfully fetched release information
156173
await promisify(fs.writeFile)(offlineCache, JSON.stringify(latestInfoParsed), { encoding: 'utf-8' });
@@ -160,7 +177,7 @@ async function getLatestReleaseMetadata(context: ExtensionContext): Promise<IRel
160177
try {
161178
const cachedInfo = await promisify(fs.readFile)(offlineCache, { encoding: 'utf-8' });
162179

163-
const cachedInfoParsed = JSON.parse(cachedInfo);
180+
const cachedInfoParsed = validate.parseAndValidate(cachedInfo, cachedReleaseValidator);
164181
window.showWarningMessage(
165182
`Couldn't get the latest haskell-language-server releases from GitHub, used local cache instead:\n${githubError.message}`
166183
);

src/validation.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Open for other validation libraries, but I haven't found any that were small enough,
2+
// strongly typed and maintained.
3+
4+
export interface IValidationError {
5+
path: PropertyKey[];
6+
message: string;
7+
}
8+
9+
export type ValidationResult<T> =
10+
| {
11+
success: true;
12+
value: T;
13+
}
14+
| {
15+
success: false;
16+
errors: IValidationError[];
17+
};
18+
19+
export type Validator<T> = (scrutinee: unknown) => ValidationResult<T>;
20+
21+
function success<T>(t: T): ValidationResult<T> {
22+
return {
23+
success: true,
24+
value: t,
25+
};
26+
}
27+
function failure<T>(errors: IValidationError[]): ValidationResult<T> {
28+
return {
29+
success: false,
30+
errors,
31+
};
32+
}
33+
34+
function typeGuard<T>(name: string, guard: (arg: unknown) => arg is T): Validator<T> {
35+
return (scrutinee) => {
36+
if (guard(scrutinee)) {
37+
return success(scrutinee);
38+
}
39+
return failure([
40+
{
41+
path: [],
42+
message: `expected a ${name}`,
43+
},
44+
]);
45+
};
46+
}
47+
48+
export function string(): Validator<string> {
49+
function stringGuard(arg: unknown): arg is string {
50+
return typeof arg === 'string';
51+
}
52+
return typeGuard('string', stringGuard);
53+
}
54+
55+
export function boolean(): Validator<boolean> {
56+
function boolGuard(arg: unknown): arg is boolean {
57+
return typeof arg === 'boolean';
58+
}
59+
return typeGuard('boolean', boolGuard);
60+
}
61+
62+
function hasOwnProperty<X extends {}, Y extends PropertyKey>(obj: X, prop: Y): obj is X & Record<Y, unknown> {
63+
return obj.hasOwnProperty(prop);
64+
}
65+
66+
export function object<S>(schema: { [P in keyof S]: Validator<S[P]> }): Validator<{ [P in keyof S]: S[P] }> {
67+
return (scrutinee) => {
68+
if (typeof scrutinee !== 'object' || scrutinee === null) {
69+
return failure([
70+
{
71+
path: [],
72+
message: 'expected an object',
73+
},
74+
]);
75+
}
76+
77+
const errors: IValidationError[] = [];
78+
let validationFailed = false;
79+
for (const key in schema) {
80+
if (!schema[key]) {
81+
continue;
82+
}
83+
84+
// If the deserialized value doesn't have the key, use `undefined` as a placeholder
85+
// might get replaced with a default value
86+
const existingSub = hasOwnProperty(scrutinee, key) ? scrutinee[key] : undefined;
87+
const subResult = schema[key](existingSub);
88+
89+
if (subResult.success) {
90+
Object.assign(scrutinee, { [key]: subResult.value });
91+
} else {
92+
subResult.errors.forEach((val) =>
93+
errors.push({
94+
path: [key, ...val.path],
95+
message: val.message,
96+
})
97+
);
98+
validationFailed = true;
99+
}
100+
}
101+
102+
if (!validationFailed) {
103+
return {
104+
success: true,
105+
// when we get here, all properties in S have been validated and assigned, so
106+
// this type assertion is okay.
107+
value: scrutinee as { [P in keyof S]: S[P] },
108+
};
109+
}
110+
111+
return {
112+
success: false,
113+
errors,
114+
};
115+
};
116+
}
117+
118+
export function array<S>(memberValidator: Validator<S>): Validator<S[]> {
119+
return (scrutinee) => {
120+
if (!(scrutinee instanceof Array)) {
121+
return {
122+
success: false,
123+
errors: [
124+
{
125+
path: [],
126+
message: 'expected an array',
127+
},
128+
],
129+
};
130+
}
131+
132+
const errors: IValidationError[] = [];
133+
let validationFailed = false;
134+
for (let i = 0; i < scrutinee.length; ++i) {
135+
const subResult = memberValidator(scrutinee[i]);
136+
if (subResult.success) {
137+
scrutinee[i] = subResult.value;
138+
} else {
139+
subResult.errors.forEach((val) =>
140+
errors.push({
141+
path: [i, ...val.path],
142+
message: val.message,
143+
})
144+
);
145+
validationFailed = true;
146+
}
147+
}
148+
149+
if (!validationFailed) {
150+
return {
151+
success: true,
152+
value: scrutinee,
153+
};
154+
}
155+
156+
return {
157+
success: false,
158+
errors,
159+
};
160+
};
161+
}
162+
163+
export function optional<T>(validator: Validator<T>): Validator<T | null> {
164+
return (scrutinee) => {
165+
if (scrutinee === null) {
166+
return {
167+
success: true,
168+
value: null,
169+
};
170+
}
171+
return validator(scrutinee);
172+
};
173+
}
174+
175+
export class ValidationError extends Error {
176+
constructor(public errors: IValidationError[], message?: string) {
177+
super(`validation failure: ${errors.length} errors`);
178+
}
179+
}
180+
181+
export function parseAndValidate<T>(text: string, validator: Validator<T>): T {
182+
const value: unknown = JSON.parse(text);
183+
const result = validator(value);
184+
if (result.success) {
185+
return result.value;
186+
}
187+
throw new ValidationError(result.errors);
188+
}

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"rootDir": ".",
1010
"noUnusedLocals": true,
1111
"strict": true,
12+
"noImplicitAny": true,
1213
"noImplicitReturns": true,
1314
"noFallthroughCasesInSwitch": true,
1415
"strictNullChecks": true

0 commit comments

Comments
 (0)