Skip to content
This repository was archived by the owner on Jan 19, 2025. It is now read-only.

Commit 5e0c219

Browse files
authored
feat: versioning for json data (#805)
* chore(data): add schema version to data files * feat(parser): add schema version to generated files * feat(gui): validate version of usages file * feat(gui): validate version of API file * feat(gui): validate version of annotation file * feat(gui): validate version of UI data * feat(gui): clear API data in IndexedDB * style: apply automatic fixes of linters * fix(gui): circular import Co-authored-by: lars-reimann <[email protected]>
1 parent 1f731b2 commit 5e0c219

File tree

21 files changed

+192
-29
lines changed

21 files changed

+192
-29
lines changed

api-editor/gui/src/features/annotations/AnnotationImportDialog.tsx

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,64 @@ import {
1212
ModalHeader,
1313
ModalOverlay,
1414
Text as ChakraText,
15+
useToast,
1516
} from '@chakra-ui/react';
1617
import React, { useState } from 'react';
1718
import { useAppDispatch } from '../../app/hooks';
1819
import { StyledDropzone } from '../../common/StyledDropzone';
1920
import { isValidJsonFile } from '../../common/util/validation';
20-
import { AnnotationStore, initialAnnotationStore, mergeAnnotationStore, setAnnotationStore } from './annotationSlice';
21+
import {
22+
AnnotationStore,
23+
EXPECTED_ANNOTATION_STORE_SCHEMA_VERSION,
24+
initialAnnotationStore,
25+
mergeAnnotationStore,
26+
setAnnotationStore,
27+
VersionedAnnotationStore,
28+
} from './annotationSlice';
2129
import { hideAnnotationImportDialog, toggleAnnotationImportDialog } from '../ui/uiSlice';
2230

2331
export const AnnotationImportDialog: React.FC = function () {
32+
const toast = useToast();
2433
const [fileName, setFileName] = useState('');
25-
const [newAnnotationStore, setNewAnnotationStore] = useState<AnnotationStore>(initialAnnotationStore);
34+
const [newAnnotationStore, setNewAnnotationStore] = useState<VersionedAnnotationStore>(initialAnnotationStore);
2635
const dispatch = useAppDispatch();
2736

37+
const validate = () => {
38+
if (!fileName) {
39+
toast({
40+
title: 'No File Selected',
41+
description: 'Select a file to import or cancel this dialog.',
42+
status: 'error',
43+
duration: 4000,
44+
});
45+
return false;
46+
}
47+
48+
if ((newAnnotationStore.schemaVersion ?? 1) !== EXPECTED_ANNOTATION_STORE_SCHEMA_VERSION) {
49+
toast({
50+
title: 'Old Annotation File',
51+
description: 'This file is not compatible with the current version of the API Editor.',
52+
status: 'error',
53+
duration: 4000,
54+
});
55+
return false;
56+
}
57+
58+
return true;
59+
};
2860
const merge = () => {
29-
if (fileName) {
61+
if (validate()) {
62+
delete newAnnotationStore.schemaVersion;
3063
dispatch(mergeAnnotationStore(newAnnotationStore));
64+
dispatch(hideAnnotationImportDialog());
3165
}
32-
dispatch(hideAnnotationImportDialog());
3366
};
3467
const replace = () => {
35-
if (fileName) {
68+
if (validate()) {
69+
delete newAnnotationStore.schemaVersion;
3670
dispatch(setAnnotationStore(newAnnotationStore));
71+
dispatch(hideAnnotationImportDialog());
3772
}
38-
dispatch(hideAnnotationImportDialog());
3973
};
4074
const close = () => dispatch(toggleAnnotationImportDialog());
4175

api-editor/gui/src/features/annotations/annotationSlice.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import * as idb from 'idb-keyval';
33
import { RootState } from '../../app/store';
44
import { isValidUsername } from '../../common/util/validation';
55

6+
export const EXPECTED_ANNOTATION_STORE_SCHEMA_VERSION = 1;
7+
export const EXPECTED_ANNOTATION_SLICE_SCHEMA_VERSION = 1;
8+
69
/**
710
* How many annotations can be applied to a class at once.
811
*/
@@ -11,16 +14,17 @@ export const maximumNumberOfClassAnnotations = 5;
1114
/**
1215
* How many annotations can be applied to a function at once.
1316
*/
14-
export const maximumNumberOfFunctionAnnotations = 7;
17+
export const maximumNumberOfFunctionAnnotations = 8;
1518

1619
/**
1720
* How many annotations can be applied to a parameter at once.
1821
*/
19-
export const maximumNumberOfParameterAnnotations = 8;
22+
export const maximumNumberOfParameterAnnotations = 9;
2023

2124
const maximumUndoHistoryLength = 10;
2225

2326
export interface AnnotationSlice {
27+
schemaVersion?: number;
2428
annotations: AnnotationStore;
2529
queue: AnnotationStore[];
2630
queueIndex: number;
@@ -75,6 +79,10 @@ export interface AnnotationStore {
7579
};
7680
}
7781

82+
export interface VersionedAnnotationStore extends AnnotationStore {
83+
schemaVersion?: number;
84+
}
85+
7886
export interface Annotation {
7987
/**
8088
* ID of the annotated Python declaration.
@@ -301,6 +309,10 @@ export const initialAnnotationSlice: AnnotationSlice = {
301309
export const initializeAnnotations = createAsyncThunk('annotations/initialize', async () => {
302310
try {
303311
const storedAnnotations = (await idb.get('annotations')) as AnnotationSlice;
312+
if ((storedAnnotations.schemaVersion ?? 1) !== EXPECTED_ANNOTATION_SLICE_SCHEMA_VERSION) {
313+
return initialAnnotationSlice;
314+
}
315+
304316
return {
305317
...initialAnnotationSlice,
306318
...storedAnnotations,

api-editor/gui/src/features/menuBar/HelpMenu.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Button, Icon, Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@chakra-ui/react';
1+
import { Box, Button, Icon, Menu, MenuButton, MenuDivider, MenuGroup, MenuItem, MenuList } from '@chakra-ui/react';
22
import React from 'react';
33
import { FaBug, FaChevronDown, FaLightbulb } from 'react-icons/fa';
44
import { bugReportURL, featureRequestURL, userGuideURL } from '../externalLinks/urlBuilder';
@@ -21,6 +21,7 @@ export const HelpMenu = function () {
2121
User Guide
2222
</MenuItem>
2323
</MenuGroup>
24+
<MenuDivider />
2425
<MenuGroup title="Feedback">
2526
<MenuItem
2627
paddingLeft={8}

api-editor/gui/src/features/packageData/PackageDataImportDialog.tsx

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ModalHeader,
1313
ModalOverlay,
1414
Text as ChakraText,
15+
useToast,
1516
} from '@chakra-ui/react';
1617
import React, { useState } from 'react';
1718
import { useNavigate } from 'react-router-dom';
@@ -25,23 +26,43 @@ import { persistPythonPackage, setPythonPackage } from './apiSlice';
2526
import { resetUsages } from '../usages/usageSlice';
2627

2728
export const PackageDataImportDialog: React.FC = function () {
29+
const toast = useToast();
2830
const [fileName, setFileName] = useState('');
29-
const [newPythonPackage, setNewPythonPackage] = useState<string>();
31+
const [newPythonPackageString, setNewPythonPackageString] = useState<string>();
3032
const navigate = useNavigate();
3133
const dispatch = useAppDispatch();
3234

3335
const submit = async () => {
34-
if (newPythonPackage) {
35-
const parsedPythonPackage = JSON.parse(newPythonPackage) as PythonPackageJson;
36+
if (!fileName) {
37+
toast({
38+
title: 'No File Selected',
39+
description: 'Select a file to import or cancel this dialog.',
40+
status: 'error',
41+
duration: 4000,
42+
});
43+
return;
44+
}
3645

37-
dispatch(setPythonPackage(parsePythonPackageJson(parsedPythonPackage)));
38-
dispatch(persistPythonPackage(parsedPythonPackage));
46+
if (newPythonPackageString) {
47+
const pythonPackageJson = JSON.parse(newPythonPackageString) as PythonPackageJson;
48+
const pythonPackage = parsePythonPackageJson(pythonPackageJson);
49+
if (pythonPackage) {
50+
dispatch(setPythonPackage(pythonPackage));
51+
dispatch(persistPythonPackage(pythonPackageJson));
3952

40-
// Reset other slices
41-
dispatch(resetAnnotationStore());
42-
dispatch(resetUsages());
43-
dispatch(resetUIAfterAPIImport());
44-
navigate('/');
53+
// Reset other slices
54+
dispatch(resetAnnotationStore());
55+
dispatch(resetUsages());
56+
dispatch(resetUIAfterAPIImport());
57+
navigate('/');
58+
} else {
59+
toast({
60+
title: 'Old API File',
61+
description: 'This file is not compatible with the current version of the API Editor.',
62+
status: 'error',
63+
duration: 4000,
64+
});
65+
}
4566
}
4667
};
4768
const close = () => dispatch(toggleAPIImportDialog());
@@ -56,7 +77,7 @@ export const PackageDataImportDialog: React.FC = function () {
5677
const reader = new FileReader();
5778
reader.onload = () => {
5879
if (typeof reader.result === 'string') {
59-
setNewPythonPackage(reader.result);
80+
setNewPythonPackageString(reader.result);
6081
dispatch(resetAnnotationStore());
6182
}
6283
};

api-editor/gui/src/features/packageData/apiSlice.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,43 @@ import { selectUsages } from '../usages/usageSlice';
88
import { selectAnnotationStore } from '../annotations/annotationSlice';
99
import { PythonDeclaration } from './model/PythonDeclaration';
1010

11+
export const EXPECTED_API_SCHEMA_VERSION = 1;
12+
1113
export interface APIState {
1214
pythonPackage: PythonPackage;
1315
}
1416

1517
// Initial state -------------------------------------------------------------------------------------------------------
1618

19+
const initialPythonPackageJson: PythonPackageJson = {
20+
schemaVersion: EXPECTED_API_SCHEMA_VERSION,
21+
distribution: 'empty',
22+
package: 'empty',
23+
version: '0.0.1',
24+
modules: [],
25+
classes: [],
26+
functions: [],
27+
};
28+
29+
const initialPythonPackage = new PythonPackage('empty', 'empty', '0.0.1');
30+
1731
const initialState: APIState = {
18-
pythonPackage: new PythonPackage('empty', 'empty', '0.0.1'),
32+
pythonPackage: initialPythonPackage,
1933
};
2034

2135
// Thunks --------------------------------------------------------------------------------------------------------------
2236

2337
export const initializePythonPackage = createAsyncThunk('api/initialize', async () => {
2438
try {
2539
const storedPythonPackageJson = (await idb.get('api')) as PythonPackageJson;
40+
const pythonPackage = parsePythonPackageJson(storedPythonPackageJson);
41+
if (!pythonPackage) {
42+
await idb.set('api', initialPythonPackageJson);
43+
return initialState;
44+
}
45+
2646
return {
27-
pythonPackage: parsePythonPackageJson(storedPythonPackageJson),
47+
pythonPackage,
2848
};
2949
} catch {
3050
return initialState;

api-editor/gui/src/features/packageData/model/PythonPackageBuilder.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import { PythonPackage } from './PythonPackage';
88
import { PythonParameter, PythonParameterAssignment } from './PythonParameter';
99
import { PythonResult } from './PythonResult';
1010
import { PythonDeclaration } from './PythonDeclaration';
11+
import { EXPECTED_API_SCHEMA_VERSION } from '../apiSlice';
1112

1213
export interface PythonPackageJson {
14+
schemaVersion?: number;
1315
distribution: string;
1416
package: string;
1517
version: string;
@@ -18,7 +20,11 @@ export interface PythonPackageJson {
1820
functions: PythonFunctionJson[];
1921
}
2022

21-
export const parsePythonPackageJson = function (packageJson: PythonPackageJson): PythonPackage {
23+
export const parsePythonPackageJson = function (packageJson: PythonPackageJson): PythonPackage | null {
24+
if ((packageJson.schemaVersion ?? 1) !== EXPECTED_API_SCHEMA_VERSION) {
25+
return null;
26+
}
27+
2228
const idToDeclaration = new Map();
2329

2430
// Functions

api-editor/gui/src/features/ui/uiSlice.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import { PythonDeclaration } from '../packageData/model/PythonDeclaration';
88
import { UsageCountStore } from '../usages/model/UsageCountStore';
99
import { selectUsages } from '../usages/usageSlice';
1010

11+
const EXPECTED_UI_SCHEMA_VERSION = 1;
12+
1113
export interface Filter {
1214
filter: string;
1315
name: string;
1416
}
1517

1618
export interface UIState {
19+
schemaVersion?: number;
1720
showAnnotationImportDialog: boolean;
1821
showAPIImportDialog: boolean;
1922
showUsageImportDialog: boolean;
@@ -162,6 +165,10 @@ export const initialState: UIState = {
162165
export const initializeUI = createAsyncThunk('ui/initialize', async () => {
163166
try {
164167
const storedState = (await idb.get('ui')) as UIState;
168+
if ((storedState.schemaVersion ?? 1) !== EXPECTED_UI_SCHEMA_VERSION) {
169+
return initialState;
170+
}
171+
165172
return {
166173
...initialState,
167174
...storedState,

api-editor/gui/src/features/usages/UsageImportDialog.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ModalHeader,
1313
ModalOverlay,
1414
Text as ChakraText,
15+
useToast,
1516
} from '@chakra-ui/react';
1617
import React, { useState } from 'react';
1718
import { useAppDispatch, useAppSelector } from '../../app/hooks';
@@ -23,17 +24,38 @@ import { setUsages } from './usageSlice';
2324
import { selectRawPythonPackage } from '../packageData/apiSlice';
2425

2526
export const UsageImportDialog: React.FC = function () {
27+
const toast = useToast();
2628
const [fileName, setFileName] = useState('');
2729
const [newUsages, setNewUsages] = useState<string>();
2830
const dispatch = useAppDispatch();
2931
const api = useAppSelector(selectRawPythonPackage);
3032

3133
const submit = async () => {
34+
if (!fileName) {
35+
toast({
36+
title: 'No File Selected',
37+
description: 'Select a file to import or cancel this dialog.',
38+
status: 'error',
39+
duration: 4000,
40+
});
41+
return;
42+
}
43+
3244
if (newUsages) {
3345
const parsedUsages = JSON.parse(newUsages) as UsageCountJson;
34-
dispatch(setUsages(UsageCountStore.fromJson(parsedUsages, api)));
46+
const usageCountStore = UsageCountStore.fromJson(parsedUsages, api);
47+
if (usageCountStore) {
48+
dispatch(setUsages(usageCountStore));
49+
close();
50+
} else {
51+
toast({
52+
title: 'Old Usage Count File',
53+
description: 'This file is not compatible with the current version of the API Editor.',
54+
status: 'error',
55+
duration: 4000,
56+
});
57+
}
3558
}
36-
close();
3759
};
3860
const close = () => dispatch(toggleUsageImportDialog());
3961

api-editor/gui/src/features/usages/model/UsageCountStore.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { PythonModule } from '../../packageData/model/PythonModule';
55
import { PythonClass } from '../../packageData/model/PythonClass';
66
import { PythonFunction } from '../../packageData/model/PythonFunction';
77

8+
export const EXPECTED_USAGES_SCHEMA_VERSION = 1;
9+
810
export interface UsageCountJson {
11+
schemaVersion?: number;
912
module_counts?: {
1013
[target: string]: number;
1114
};
@@ -26,7 +29,11 @@ export interface UsageCountJson {
2629
}
2730

2831
export class UsageCountStore {
29-
static fromJson(json: UsageCountJson, api?: PythonPackage): UsageCountStore {
32+
static fromJson(json: UsageCountJson, api?: PythonPackage): UsageCountStore | null {
33+
if ((json.schemaVersion ?? 1) !== EXPECTED_USAGES_SCHEMA_VERSION) {
34+
return null;
35+
}
36+
3037
return new UsageCountStore(
3138
new Map(Object.entries(json.module_counts ?? {})),
3239
new Map(Object.entries(json.class_counts)),
@@ -86,6 +93,7 @@ export class UsageCountStore {
8693

8794
toJson(): UsageCountJson {
8895
return {
96+
schemaVersion: EXPECTED_USAGES_SCHEMA_VERSION,
8997
module_counts: Object.fromEntries(this.moduleUsages),
9098
class_counts: Object.fromEntries(this.classUsages),
9199
function_counts: Object.fromEntries(this.functionUsages),

api-editor/gui/src/features/usages/usageSlice.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const initializeUsages = createAsyncThunk('usages/initialize', async () =
1919
try {
2020
const storedUsageCountStoreJson = (await idb.get('usages')) as UsageCountJson;
2121
return {
22-
usages: UsageCountStore.fromJson(storedUsageCountStoreJson),
22+
usages: UsageCountStore.fromJson(storedUsageCountStoreJson) ?? new UsageCountStore(),
2323
};
2424
} catch {
2525
return initialState;

0 commit comments

Comments
 (0)