Skip to content

Commit d65d7eb

Browse files
max-eliasdirix
andauthored
Escape special chars in JSON Pointers
Escape special characters in JSON Pointers to support forward slashes in attribute names. JSON Schema contains JSON Pointers in "$ref" attributes and UI Schemas in the "scope" attributes. As the forward slash is used a separator in in JSON Pointers, but attributes can contain forward slashes in their name, these pointers need to be encoded and decoded. This commit adds an "encode" utility to escape JSON Pointers. Once we convert to a data path or want to access the raw name, "decode" can be used. Co-authored-by: Stefan Dirix <[email protected]>
1 parent 63b40d1 commit d65d7eb

File tree

12 files changed

+77
-23
lines changed

12 files changed

+77
-23
lines changed

packages/angular-material/src/other/master-detail/master.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
ArrayControlProps,
3434
ControlElement,
3535
createDefaultValue,
36+
decode,
3637
findUISchema,
3738
getFirstPrimitiveProp,
3839
JsonFormsState,
@@ -47,10 +48,12 @@ import {
4748
const keywords = ['#', 'properties', 'items'];
4849

4950
export const removeSchemaKeywords = (path: string) => {
50-
return path
51-
.split('/')
52-
.filter(s => !some(keywords, key => key === s))
53-
.join('.');
51+
return decode(
52+
path
53+
.split('/')
54+
.filter(s => !some(keywords, key => key === s))
55+
.join('.')
56+
);
5457
};
5558

5659
@Component({

packages/angular-material/src/other/table.renderer.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
ArrayControlProps,
3333
ControlElement,
3434
deriveTypes,
35+
encode,
3536
isObjectArrayControl,
3637
isPrimitiveArrayControl,
3738
JsonSchema,
@@ -101,7 +102,8 @@ export class TableRenderer extends JsonFormsArrayControl {
101102
): ColumnDescription[] => {
102103
if (schema.type === 'object') {
103104
return this.getValidColumnProps(schema).map(prop => {
104-
const uischema = controlWithoutLabel(`#/properties/${prop}`);
105+
const encProp = encode(prop);
106+
const uischema = controlWithoutLabel(`#/properties/${encProp}`);
105107
if (!this.isEnabled()) {
106108
setReadonly(uischema);
107109
}

packages/core/src/generators/uischema.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
Layout,
3636
UISchemaElement
3737
} from '../models';
38-
import { deriveTypes, resolveSchema } from '../util';
38+
import { deriveTypes, encode, resolveSchema } from '../util';
3939

4040
/**
4141
* Creates a new ILayout.
@@ -162,7 +162,7 @@ const generateUISchema = (
162162
const nextRef: string = currentRef + '/properties';
163163
Object.keys(jsonSchema.properties).map(propName => {
164164
let value = jsonSchema.properties[propName];
165-
const ref = `${nextRef}/${propName}`;
165+
const ref = `${nextRef}/${encode(propName)}`;
166166
if (value.$ref !== undefined) {
167167
value = resolveSchema(rootSchema, value.$ref);
168168
}

packages/core/src/util/label.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import startCase from 'lodash/startCase';
2727

2828
import { ControlElement, JsonSchema, LabelDescription } from '../models';
29+
import { decode } from './path';
2930

3031
const deriveLabel = (
3132
controlElement: ControlElement,
@@ -36,8 +37,7 @@ const deriveLabel = (
3637
}
3738
if (typeof controlElement.scope === 'string') {
3839
const ref = controlElement.scope;
39-
const label = ref.substr(ref.lastIndexOf('/') + 1);
40-
40+
const label = decode(ref.substr(ref.lastIndexOf('/') + 1));
4141
return startCase(label);
4242
}
4343

packages/core/src/util/path.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ export const toDataPathSegments = (schemaPath: string): string[] => {
6262
.replace(/oneOf\/[\d]\//g, '');
6363
const segments = s.split('/');
6464

65-
const startFromRoot = segments[0] === '#' || segments[0] === '';
65+
const decodedSegments = segments.map(decode);
66+
67+
const startFromRoot = decodedSegments[0] === '#' || decodedSegments[0] === '';
6668
const startIndex = startFromRoot ? 2 : 1;
67-
return range(startIndex, segments.length, 2).map(idx => segments[idx]);
69+
return range(startIndex, decodedSegments.length, 2).map(idx => decodedSegments[idx]);
6870
};
6971

7072
/**
@@ -88,3 +90,14 @@ export const composeWithUi = (scopableUi: Scopable, path: string): string => {
8890

8991
return isEmpty(segments) ? path : compose(path, segments.join('.'));
9092
};
93+
94+
/**
95+
* Encodes the given segment to be used as part of a JSON Pointer
96+
*
97+
* JSON Pointer has special meaning for "/" and "~", therefore these must be encoded
98+
*/
99+
export const encode = (segment: string) => segment?.replace(/~/g, '~0').replace(/\//g, '~1');
100+
/**
101+
* Decodes a given JSON Pointer segment to its "normal" representation
102+
*/
103+
export const decode = (pointerSegment: string) => pointerSegment?.replace(/~1/g, '/').replace(/~0/, '~');

packages/core/src/util/renderer.ts

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const isRequired = (
6565
): boolean => {
6666
const pathSegments = schemaPath.split('/');
6767
const lastSegment = pathSegments[pathSegments.length - 1];
68+
// Skip "properties", "items" etc. to resolve the parent
6869
const nextHigherSchemaSegments = pathSegments.slice(
6970
0,
7071
pathSegments.length - 2

packages/core/src/util/resolvers.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import isEmpty from 'lodash/isEmpty';
2727
import get from 'lodash/get';
2828
import { JsonSchema } from '../models';
29+
import { decode, encode } from './path';
2930

3031
/**
3132
* Map for storing refs and the respective schemas they are pointing to.
@@ -115,7 +116,7 @@ export const resolveSchema = (
115116
if (isEmpty(schema)) {
116117
return undefined;
117118
}
118-
const validPathSegments = schemaPath.split('/');
119+
const validPathSegments = schemaPath.split('/').map(decode);
119120
let resultSchema = schema;
120121
for (let i = 0; i < validPathSegments.length; i++) {
121122
let pathSegment = validPathSegments[i];
@@ -136,7 +137,7 @@ export const resolveSchema = (
136137
resultSchema?.anyOf ?? []
137138
);
138139
for (let item of schemas) {
139-
curSchema = resolveSchema(item, validPathSegments.slice(i).join('/'));
140+
curSchema = resolveSchema(item, validPathSegments.slice(i).map(encode).join('/'));
140141
if (curSchema) {
141142
break;
142143
}

packages/core/test/generators/uischema.test.ts

+18
Original file line numberDiff line numberDiff line change
@@ -556,3 +556,21 @@ test('generate control for nested oneOf', t => {
556556
};
557557
t.deepEqual(generateDefaultUISchema(schema), uischema);
558558
});
559+
560+
test('encode "/" in generated ui schema', t => {
561+
const schema: JsonSchema = {
562+
properties: {
563+
'some / initial / value': {
564+
type : 'integer'
565+
}
566+
}
567+
};
568+
const uischema: Layout = {
569+
type: 'VerticalLayout',
570+
elements: [{
571+
type: 'Control',
572+
scope: '#/properties/some ~1 initial ~1 value'
573+
}] as ControlElement[]
574+
};
575+
t.deepEqual(generateDefaultUISchema(schema), uischema);
576+
});

packages/core/test/util/path.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ test('toDataPath use of encoded paths relative without /', t => {
8787
const fooBar = encodeURIComponent('foo/bar');
8888
t.is(toDataPath(`properties/${fooBar}`), `${fooBar}`);
8989
});
90+
test('toDataPath use of encoded special character in pathname', t => {
91+
t.is(toDataPath('properties/foo~0bar~1baz'), 'foo~bar/baz');
92+
});
9093
test('resolve instance', t => {
9194
const instance = { foo: 123 };
9295
const result = Resolve.data(instance, toDataPath('#/properties/foo'));

packages/core/test/util/resolvers.test.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,17 @@ test('resolveSchema - resolves schema with any ', t => {
6363
t.deepEqual(resolveSchema(schema, '#/properties/description/properties/index'), {type: 'number'});
6464
t.deepEqual(resolveSchema(schema, '#/properties/description/properties/exist'), {type: 'boolean'});
6565
t.is(resolveSchema(schema, '#/properties/description/properties/notfound'), undefined);
66-
});
66+
});
67+
68+
test('resolveSchema - resolves schema with encoded characters', t => {
69+
const schema = {
70+
type: 'object',
71+
properties: {
72+
'foo / ~ bar': {
73+
type: 'integer'
74+
}
75+
}
76+
};
77+
t.deepEqual(resolveSchema(schema, '#/properties/foo ~1 ~0 bar'), {type: 'integer'});
78+
t.is(resolveSchema(schema, '#/properties/foo / bar'), undefined);
79+
});

packages/material/src/complex/MaterialTableControl.tsx

+5-5
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ import {
5353
Paths,
5454
Resolve,
5555
JsonFormsRendererRegistryEntry,
56-
JsonFormsCellRendererRegistryEntry
56+
JsonFormsCellRendererRegistryEntry,
57+
encode
5758
} from '@jsonforms/core';
5859
import DeleteIcon from '@mui/icons-material/Delete';
5960
import ArrowDownward from '@mui/icons-material/ArrowDownward';
@@ -206,18 +207,17 @@ interface NonEmptyCellComponentProps {
206207
cells?: JsonFormsCellRendererRegistryEntry[],
207208
isValid: boolean
208209
}
209-
const NonEmptyCellComponent = React.memo(({path, propName, schema,rootSchema, errors, enabled, renderers, cells, isValid}:NonEmptyCellComponentProps) => {
210-
210+
const NonEmptyCellComponent = React.memo(({path, propName, schema, rootSchema, errors, enabled, renderers, cells, isValid}:NonEmptyCellComponentProps) => {
211211
return (
212212
<NoBorderTableCell>
213213
{schema.properties ? (
214214
<DispatchCell
215215
schema={Resolve.schema(
216216
schema,
217-
`#/properties/${propName}`,
217+
`#/properties/${encode(propName)}`,
218218
rootSchema
219219
)}
220-
uischema={controlWithoutLabel(`#/properties/${propName}`)}
220+
uischema={controlWithoutLabel(`#/properties/${encode(propName)}`)}
221221
path={path}
222222
enabled={enabled}
223223
renderers={renderers}

packages/vanilla/src/complex/TableArrayControl.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ import {
3939
RankedTester,
4040
Resolve,
4141
Test,
42-
getControlPath
42+
getControlPath,
43+
encode
4344
} from '@jsonforms/core';
4445
import { DispatchCell, withJsonFormsArrayControlProps } from '@jsonforms/react';
4546
import { withVanillaControlProps } from '../util';
@@ -167,12 +168,11 @@ class TableArrayControl extends React.Component<ArrayControlProps & VanillaRende
167168
childPath,
168169
prop.toString()
169170
);
170-
171171
return (
172172
<td key={childPropPath}>
173173
<DispatchCell
174-
schema={Resolve.schema(schema, `#/properties/${prop}`, rootSchema)}
175-
uischema={createControlElement(prop)}
174+
schema={Resolve.schema(schema, `#/properties/${encode(prop)}`, rootSchema)}
175+
uischema={createControlElement(encode(prop))}
176176
path={childPath + '.' + prop}
177177
/>
178178
</td>

0 commit comments

Comments
 (0)