Skip to content

Commit 5e04ffb

Browse files
authored
Merge pull request #827 from fractal-analytics-platform/import-json-schema-validation
Fixed JSON schema validation during arguments import
2 parents 96c6d9f + 9f6cc6a commit 5e04ffb

File tree

13 files changed

+194
-66
lines changed

13 files changed

+194
-66
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
# Unreleased
44

55
* Added body request normalizer (\#825);
6+
* Fixed JSON schema validation during arguments import (\#827);
7+
* Updated slim-select version to support range selection (\#827);
8+
* Supported pre/post `pinned_package_versions` in task-collection (\#827);
69

710
# 1.19.3
811

__tests__/v2/TaskCollection.test.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,13 @@ describe('TaskCollection', () => {
147147

148148
const [key1, key2] = screen.getAllByRole('textbox', { name: 'Name' });
149149
const [value1, value2] = screen.getAllByRole('textbox', { name: 'Version' });
150+
const [pre1] = screen.getAllByRole('radio', { name: 'Pre' });
150151

151152
await fireEvent.input(key1, { target: { value: 'package1' } });
152153
await fireEvent.input(value1, { target: { value: '1.2.3' } });
153154
await fireEvent.input(key2, { target: { value: 'package2' } });
154155
await fireEvent.input(value2, { target: { value: '0.0.8' } });
156+
await fireEvent.click(pre1);
155157

156158
const packageInput = screen.getByRole('textbox', { name: 'Package' });
157159
await fireEvent.input(packageInput, { target: { value: 'main-package' } });
@@ -165,8 +167,10 @@ describe('TaskCollection', () => {
165167
// @ts-expect-error
166168
body: expect.toBeFormDataWith({
167169
package: 'main-package',
168-
pinned_package_versions: JSON.stringify({
169-
package1: '1.2.3',
170+
pinned_package_versions_pre: JSON.stringify({
171+
package1: '1.2.3'
172+
}),
173+
pinned_package_versions_post: JSON.stringify({
170174
package2: '0.0.8'
171175
})
172176
})

components/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"color-hash": "^2.0.2",
5555
"dompurify": "^3.2.3",
5656
"marked": "^7.0.4",
57-
"slim-select": "^2.8.1",
57+
"slim-select": "^2.12.1",
5858
"svelte-preprocess": "^6.0.3"
5959
},
6060
"svelte": "./dist/index.js",

components/src/lib/tasks/FilteredTasksTable.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@
376376
</div>
377377
</div>
378378
{#if allRows.length === 0}
379-
<p>
379+
<p class="mt-4">
380380
There are no available tasks. You can add new tasks on the
381381
<a href="/v2/tasks/management">Tasks management</a> page.
382382
</p>

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"jose": "^4.14.4",
5151
"log4js": "^6.9.1",
5252
"marked": "^7.0.4",
53-
"slim-select": "^2.8.1"
53+
"slim-select": "^2.12.1"
5454
},
5555
"files": [
5656
"build"

src/lib/components/v2/tasks/TaskCollection.svelte

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { FormErrorHandler } from '$lib/common/errors';
33
import TaskGroupSelector from './TaskGroupSelector.svelte';
44
import { recentActivities } from '$lib/stores';
5+
import { PropertyDescription } from 'fractal-components';
56
67
/**
78
* @typedef {Object} Props
@@ -16,7 +17,7 @@
1617
let package_version = $state('');
1718
let python_version = $state('');
1819
let package_extras = $state('');
19-
/** @type {{key: string, value: string}[]} */
20+
/** @type {Array<{key: string, value: string, type: 'pre' | 'post'}>} */
2021
let pinnedPackageVersions = $state([]);
2122
let privateTask = $state(false);
2223
let selectedGroup = $state(null);
@@ -78,9 +79,13 @@
7879
formData.append('package_version', package_version);
7980
}
8081
81-
const ppv = getPinnedPackageVersionsMap();
82-
if (ppv) {
83-
formData.append('pinned_package_versions', JSON.stringify(ppv));
82+
const ppvPre = getPinnedPackageVersionsMap('pre');
83+
if (ppvPre) {
84+
formData.append('pinned_package_versions_pre', JSON.stringify(ppvPre));
85+
}
86+
const ppvPost = getPinnedPackageVersionsMap('post');
87+
if (ppvPost) {
88+
formData.append('pinned_package_versions_post', JSON.stringify(ppvPost));
8489
}
8590
8691
let url = `/api/v2/task/collect/pip?private=${privateTask}`;
@@ -115,13 +120,14 @@
115120
}
116121
117122
/**
123+
* @param {'pre'|'post'} type
118124
* @returns {{[key: string]: string}|undefined}
119125
*/
120-
function getPinnedPackageVersionsMap() {
126+
function getPinnedPackageVersionsMap(type) {
121127
/** @type {{[key: string]: string}} */
122128
const map = {};
123129
for (const ppv of pinnedPackageVersions) {
124-
if (ppv.key && ppv.value) {
130+
if (ppv.key && ppv.value && ppv.type === type) {
125131
map[ppv.key] = ppv.value;
126132
}
127133
}
@@ -132,7 +138,7 @@
132138
}
133139
134140
function addPackageVersion() {
135-
pinnedPackageVersions = [...pinnedPackageVersions, { key: '', value: '' }];
141+
pinnedPackageVersions = [...pinnedPackageVersions, { key: '', value: '', type: 'post' }];
136142
}
137143
138144
/**
@@ -275,24 +281,51 @@
275281
{/if}
276282
{#each pinnedPackageVersions as ppv, i (i)}
277283
<div class="row">
278-
<div class="col-xl-6 col-lg-8 col-md-12 mb-2">
284+
<div class="col-xl-8 col-lg-10 col-md-12 mb-2">
279285
<div class="input-group">
280286
<label class="input-group-text" for="ppv_key_{i}">Name</label>
281287
<input
282288
type="text"
283-
class="form-control"
289+
class="form-control ppv-input"
284290
id="ppv_key_{i}"
285291
bind:value={ppv.key}
286292
required
287293
/>
288294
<label class="input-group-text" for="ppv_value_{i}">Version</label>
289295
<input
290296
type="text"
291-
class="form-control"
297+
class="form-control ppv-input"
292298
id="ppv_value_{i}"
293299
bind:value={ppv.value}
294300
required
295301
/>
302+
<span class="input-group-text">
303+
<div class="form-check form-check-inline">
304+
<input
305+
class="form-check-input"
306+
type="radio"
307+
name="ppv-type-{i}"
308+
id="ppv-pre-{i}"
309+
value="pre"
310+
bind:group={ppv.type}
311+
/>
312+
<label class="form-check-label" for="ppv-pre-{i}">Pre</label>
313+
</div>
314+
<div class="form-check form-check-inline me-1">
315+
<input
316+
class="form-check-input"
317+
type="radio"
318+
name="ppv-type-{i}"
319+
id="ppv-post-{i}"
320+
value="post"
321+
bind:group={ppv.type}
322+
/>
323+
<label class="form-check-label" for="ppv-post-{i}">Post</label>
324+
</div>
325+
<PropertyDescription
326+
description="Whether the pinned dependency should be installed before or after the main package"
327+
/>
328+
</span>
296329
<button
297330
class="btn btn-outline-secondary"
298331
type="button"
@@ -347,3 +380,9 @@
347380
</div>
348381
</form>
349382
</div>
383+
384+
<style>
385+
.ppv-input {
386+
min-width: 100px;
387+
}
388+
</style>

src/lib/components/v2/workflow/ImportExportArgs.svelte

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
<script>
22
import { downloadBlob } from '$lib/common/component_utilities';
33
import Modal from '$lib/components/common/Modal.svelte';
4+
import {
5+
getPropertiesToIgnore,
6+
JsonSchemaDataError,
7+
SchemaValidator,
8+
stripNullAndEmptyObjectsAndArrays
9+
} from 'fractal-components';
10+
import { adaptJsonSchema } from 'fractal-components/jschema/jschema_adapter';
11+
import JsonSchemaValidationErrors from './JsonSchemaValidationErrors.svelte';
412
513
/**
614
* @typedef {Object} Props
@@ -20,17 +28,21 @@
2028
/** @type {HTMLInputElement|undefined} */
2129
let importArgsFileInput = $state(undefined);
2230
let importArgsError = $state('');
31+
/** @type {import('ajv').ErrorObject[] | undefined} */
32+
let validationErrors = $state();
2333
2434
function onImportArgsModalOpen() {
2535
importArgsFiles = null;
2636
if (importArgsFileInput) {
2737
importArgsFileInput.value = '';
2838
}
2939
importArgsError = '';
40+
validationErrors = undefined;
3041
}
3142
3243
async function importArgs() {
3344
importArgsError = '';
45+
validationErrors = undefined;
3446
if (importArgsFiles === null || importArgsFiles.length === 0) {
3547
return;
3648
}
@@ -43,11 +55,53 @@
4355
importArgsError = "File doesn't contain valid JSON";
4456
return;
4557
}
58+
try {
59+
if (json.args_parallel) {
60+
validateArgumentsForImport(
61+
workflowTask.task.args_schema_parallel,
62+
workflowTask.task.args_schema_version,
63+
json.args_parallel
64+
);
65+
}
66+
if (json.args_non_parallel) {
67+
validateArgumentsForImport(
68+
workflowTask.task.args_schema_non_parallel,
69+
workflowTask.task.args_schema_version,
70+
json.args_non_parallel
71+
);
72+
}
73+
} catch (err) {
74+
if (err instanceof Error) {
75+
importArgsError = err.message;
76+
}
77+
return;
78+
}
4679
importArgsModal?.confirmAndHide(async function () {
4780
await onImport(json);
4881
});
4982
}
5083
84+
/**
85+
* @param {object} schema
86+
* @param {'pydantic_v1'|'pydantic_v2'} schemaVersion
87+
* @param {object} data
88+
*/
89+
function validateArgumentsForImport(schema, schemaVersion, data) {
90+
if (!schema) {
91+
return;
92+
}
93+
const jsonSchema = adaptJsonSchema(schema, getPropertiesToIgnore(false));
94+
const validator = new SchemaValidator(schemaVersion);
95+
const isSchemaValid = validator.loadSchema(jsonSchema);
96+
if (!isSchemaValid) {
97+
throw new Error('Invalid JSON Schema');
98+
}
99+
if (!validator.isValid(stripNullAndEmptyObjectsAndArrays(data))) {
100+
validationErrors = validator.getErrors() || undefined;
101+
throw new JsonSchemaDataError(validator.getErrors());
102+
}
103+
}
104+
51105
function exportArgs() {
52106
const args = {
53107
args_parallel: workflowTask.args_parallel,
@@ -120,6 +174,11 @@
120174
<div id="errorAlert-importArgumentsModal"></div>
121175
</div>
122176
</div>
177+
<div class="row mt-0">
178+
<div class="col">
179+
<JsonSchemaValidationErrors title="Invalid arguments:" {validationErrors} />
180+
</div>
181+
</div>
123182
{/snippet}
124183
{#snippet footer()}
125184
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<script>
2+
/**
3+
* @typedef {Object} Props
4+
* @property {import('ajv').ErrorObject[] | undefined} validationErrors
5+
* @property {string} title
6+
*/
7+
8+
/** @type {Props} */
9+
let { validationErrors, title } = $props();
10+
</script>
11+
12+
{#if validationErrors}
13+
<div class="alert alert-danger">
14+
<p>{title}</p>
15+
<ul id="validation-errors">
16+
{#each validationErrors as error, index (index)}
17+
<li>
18+
{#if error.instancePath !== ''}
19+
{error.instancePath}:
20+
{/if}
21+
{#if error.keyword === 'additionalProperties'}
22+
must NOT have additional property '{error.params.additionalProperty}'
23+
{:else}
24+
{error.message}
25+
{/if}
26+
<small
27+
data-bs-toggle="collapse"
28+
data-bs-target="#collapse-{index}"
29+
aria-expanded="true"
30+
aria-controls="collapse-{index}"
31+
class="text-primary"
32+
role="button"
33+
>
34+
more
35+
</small>
36+
<div
37+
id="collapse-{index}"
38+
class="accordion-collapse collapse"
39+
data-bs-parent="#validation-errors"
40+
>
41+
<div class="accordion-body">
42+
<pre class="alert alert-warning mt-1">{JSON.stringify(error, null, 2)}</pre>
43+
</div>
44+
</div>
45+
</li>
46+
{/each}
47+
</ul>
48+
</div>
49+
{/if}

0 commit comments

Comments
 (0)