Skip to content

Commit 8045882

Browse files
committed
fix: resolve JSON schema $ref/$defs for UI display of titles and descriptions
When users provide JSON schemas with $ref and $defs/definitions (e.g., in Parse JSON actions or Request triggers), the referenced definitions' titles, descriptions, and other metadata were not displayed in the designer UI. Root cause: The SchemaProcessor only handled cyclical $refs left over from Swagger dereferencing. For standalone user-provided schemas, $ref pointers against $defs/definitions were never resolved, causing all metadata to be lost. Changes: - Add dereferenceJsonSchema() utility that resolves local $ref pointers in standalone JSON schemas against their own $defs or definitions - Integrate it in getUpdatedManifestForSchemaDependency() to dereference user-provided schemas before they reach SchemaProcessor - Add $ref and $defs to IJsonSchema type for Draft 2019-09+ support - Add 13 unit tests covering all resolution scenarios Closes #8689
1 parent f232b9e commit 8045882

File tree

5 files changed

+469
-1
lines changed

5 files changed

+469
-1
lines changed

libs/designer/src/lib/core/utils/outputs.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
OutputSource,
2828
clone,
2929
create,
30+
dereferenceJsonSchema,
3031
equals,
3132
getBrandColorFromConnector,
3233
getIconUriFromConnector,
@@ -318,7 +319,10 @@ export const getUpdatedManifestForSchemaDependency = (manifest: OperationManifes
318319
case 'Value': {
319320
if (segment.type === ValueSegmentType.LITERAL) {
320321
try {
321-
schemaToReplace = JSON.parse(segment.value);
322+
const parsedSchema = JSON.parse(segment.value);
323+
// Resolve $ref pointers against $defs/definitions so titles, descriptions,
324+
// and other metadata from referenced definitions are preserved in the UI.
325+
schemaToReplace = dereferenceJsonSchema(parsedSchema);
322326
} catch {} // eslint-disable-line no-empty
323327
}
324328
break;

libs/logic-apps-shared/src/parsers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './lib/common/helpers/dereferenceJsonSchema';
12
export * from './lib/common/helpers/keysutility';
23
export * from './lib/common/helpers/expression';
34
export * from './lib/common/helpers/utils';
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { dereferenceJsonSchema } from '../dereferenceJsonSchema';
3+
4+
describe('dereferenceJsonSchema', () => {
5+
it('should return schema unchanged when no $defs or definitions exist', () => {
6+
const schema = {
7+
type: 'object',
8+
properties: {
9+
name: { type: 'string', title: 'Name' },
10+
},
11+
};
12+
13+
const result = dereferenceJsonSchema(schema) as any;
14+
expect(result).toEqual({
15+
type: 'object',
16+
properties: {
17+
name: { type: 'string', title: 'Name' },
18+
},
19+
});
20+
});
21+
22+
it('should resolve $ref using $defs (JSON Schema 2019-09+)', () => {
23+
const schema: any = {
24+
type: 'object',
25+
properties: {
26+
address: { $ref: '#/$defs/Address' },
27+
},
28+
$defs: {
29+
Address: {
30+
type: 'object',
31+
title: 'Mailing Address',
32+
description: 'A physical mailing address',
33+
properties: {
34+
street: { type: 'string', title: 'Street' },
35+
city: { type: 'string', title: 'City' },
36+
},
37+
},
38+
},
39+
};
40+
41+
const result = dereferenceJsonSchema(schema) as any;
42+
expect(result.properties.address).toEqual({
43+
type: 'object',
44+
title: 'Mailing Address',
45+
description: 'A physical mailing address',
46+
properties: {
47+
street: { type: 'string', title: 'Street' },
48+
city: { type: 'string', title: 'City' },
49+
},
50+
});
51+
// $defs should be stripped from the output
52+
expect(result.$defs).toBeUndefined();
53+
});
54+
55+
it('should resolve $ref using definitions (JSON Schema Draft 4/7)', () => {
56+
const schema: any = {
57+
type: 'object',
58+
properties: {
59+
user: { $ref: '#/definitions/User' },
60+
},
61+
definitions: {
62+
User: {
63+
type: 'object',
64+
title: 'User Profile',
65+
description: 'A user profile object',
66+
properties: {
67+
name: { type: 'string', title: 'Full Name' },
68+
email: { type: 'string', title: 'Email Address' },
69+
},
70+
},
71+
},
72+
};
73+
74+
const result = dereferenceJsonSchema(schema) as any;
75+
expect(result.properties.user).toEqual({
76+
type: 'object',
77+
title: 'User Profile',
78+
description: 'A user profile object',
79+
properties: {
80+
name: { type: 'string', title: 'Full Name' },
81+
email: { type: 'string', title: 'Email Address' },
82+
},
83+
});
84+
expect(result.definitions).toBeUndefined();
85+
});
86+
87+
it('should resolve nested $refs (definition referencing another definition)', () => {
88+
const schema: any = {
89+
type: 'object',
90+
properties: {
91+
person: { $ref: '#/$defs/Person' },
92+
},
93+
$defs: {
94+
Person: {
95+
type: 'object',
96+
title: 'Person',
97+
properties: {
98+
name: { type: 'string' },
99+
address: { $ref: '#/$defs/Address' },
100+
},
101+
},
102+
Address: {
103+
type: 'object',
104+
title: 'Address',
105+
description: 'Postal address',
106+
properties: {
107+
street: { type: 'string' },
108+
zip: { type: 'string' },
109+
},
110+
},
111+
},
112+
};
113+
114+
const result = dereferenceJsonSchema(schema) as any;
115+
expect(result.properties.person.title).toBe('Person');
116+
expect(result.properties.person.properties.address.title).toBe('Address');
117+
expect(result.properties.person.properties.address.description).toBe('Postal address');
118+
expect(result.properties.person.properties.address.properties.street).toEqual({ type: 'string' });
119+
});
120+
121+
it('should handle cyclical $refs without infinite loop', () => {
122+
const schema: any = {
123+
type: 'object',
124+
properties: {
125+
node: { $ref: '#/$defs/TreeNode' },
126+
},
127+
$defs: {
128+
TreeNode: {
129+
type: 'object',
130+
title: 'Tree Node',
131+
properties: {
132+
value: { type: 'string' },
133+
children: {
134+
type: 'array',
135+
items: { $ref: '#/$defs/TreeNode' },
136+
},
137+
},
138+
},
139+
},
140+
};
141+
142+
const result = dereferenceJsonSchema(schema) as any;
143+
expect(result.properties.node.title).toBe('Tree Node');
144+
expect(result.properties.node.properties.value).toEqual({ type: 'string' });
145+
// The cyclical $ref should be resolved to a safe fallback
146+
expect(result.properties.node.properties.children.items).toEqual({ type: 'object' });
147+
});
148+
149+
it('should keep external URL $refs unchanged', () => {
150+
const schema: any = {
151+
type: 'object',
152+
properties: {
153+
external: { $ref: 'https://example.com/schemas/Foo.json' },
154+
},
155+
$defs: {},
156+
};
157+
158+
// Empty $defs means nothing to resolve, so schema passes through as-is
159+
const result = dereferenceJsonSchema(schema) as any;
160+
expect(result.properties.external.$ref).toBe('https://example.com/schemas/Foo.json');
161+
});
162+
163+
it('should keep $ref when pointing to non-existent definition', () => {
164+
const schema: any = {
165+
type: 'object',
166+
properties: {
167+
item: { $ref: '#/$defs/DoesNotExist' },
168+
},
169+
$defs: {
170+
Other: { type: 'string' },
171+
},
172+
};
173+
174+
const result = dereferenceJsonSchema(schema) as any;
175+
expect(result.properties.item.$ref).toBe('#/$defs/DoesNotExist');
176+
});
177+
178+
it('should preserve title and description from resolved definitions', () => {
179+
const schema: any = {
180+
type: 'object',
181+
properties: {
182+
status: { $ref: '#/$defs/StatusEnum' },
183+
},
184+
$defs: {
185+
StatusEnum: {
186+
type: 'string',
187+
title: 'Status Code',
188+
description: 'The current status of the request',
189+
enum: ['pending', 'active', 'completed'],
190+
},
191+
},
192+
};
193+
194+
const result = dereferenceJsonSchema(schema) as any;
195+
expect(result.properties.status.title).toBe('Status Code');
196+
expect(result.properties.status.description).toBe('The current status of the request');
197+
expect(result.properties.status.enum).toEqual(['pending', 'active', 'completed']);
198+
expect(result.properties.status.type).toBe('string');
199+
});
200+
201+
it('should resolve multiple $refs in the same schema', () => {
202+
const schema: any = {
203+
type: 'object',
204+
properties: {
205+
billing: { $ref: '#/$defs/Address' },
206+
shipping: { $ref: '#/$defs/Address' },
207+
contact: { $ref: '#/$defs/ContactInfo' },
208+
},
209+
$defs: {
210+
Address: {
211+
type: 'object',
212+
title: 'Address',
213+
properties: {
214+
street: { type: 'string' },
215+
},
216+
},
217+
ContactInfo: {
218+
type: 'object',
219+
title: 'Contact Information',
220+
properties: {
221+
phone: { type: 'string' },
222+
},
223+
},
224+
},
225+
};
226+
227+
const result = dereferenceJsonSchema(schema) as any;
228+
expect(result.properties.billing.title).toBe('Address');
229+
expect(result.properties.shipping.title).toBe('Address');
230+
expect(result.properties.contact.title).toBe('Contact Information');
231+
});
232+
233+
it('should handle $ref in array items', () => {
234+
const schema: any = {
235+
type: 'object',
236+
properties: {
237+
items: {
238+
type: 'array',
239+
items: { $ref: '#/$defs/Product' },
240+
},
241+
},
242+
$defs: {
243+
Product: {
244+
type: 'object',
245+
title: 'Product',
246+
description: 'A product in the catalog',
247+
properties: {
248+
name: { type: 'string' },
249+
price: { type: 'number' },
250+
},
251+
},
252+
},
253+
};
254+
255+
const result = dereferenceJsonSchema(schema) as any;
256+
expect(result.properties.items.items.title).toBe('Product');
257+
expect(result.properties.items.items.description).toBe('A product in the catalog');
258+
});
259+
260+
it('should return null/undefined/non-object values as-is', () => {
261+
expect(dereferenceJsonSchema(null as any)).toBeNull();
262+
expect(dereferenceJsonSchema(undefined as any)).toBeUndefined();
263+
expect(dereferenceJsonSchema('string' as any)).toBe('string');
264+
});
265+
266+
it('should handle empty $defs gracefully', () => {
267+
const schema: any = {
268+
type: 'object',
269+
properties: {
270+
name: { type: 'string' },
271+
},
272+
$defs: {},
273+
};
274+
275+
const result = dereferenceJsonSchema(schema) as any;
276+
expect(result).toEqual({
277+
type: 'object',
278+
properties: {
279+
name: { type: 'string' },
280+
},
281+
});
282+
});
283+
284+
it('should handle indirect cyclical $refs (A -> B -> A)', () => {
285+
const schema: any = {
286+
type: 'object',
287+
properties: {
288+
person: { $ref: '#/$defs/Person' },
289+
},
290+
$defs: {
291+
Person: {
292+
type: 'object',
293+
title: 'Person',
294+
properties: {
295+
name: { type: 'string' },
296+
employer: { $ref: '#/$defs/Company' },
297+
},
298+
},
299+
Company: {
300+
type: 'object',
301+
title: 'Company',
302+
properties: {
303+
name: { type: 'string' },
304+
ceo: { $ref: '#/$defs/Person' },
305+
},
306+
},
307+
},
308+
};
309+
310+
const result = dereferenceJsonSchema(schema) as any;
311+
expect(result.properties.person.title).toBe('Person');
312+
expect(result.properties.person.properties.employer.title).toBe('Company');
313+
// The cyclical back-reference to Person should be resolved to a safe fallback
314+
expect(result.properties.person.properties.employer.properties.ceo).toEqual({ type: 'object' });
315+
});
316+
317+
it('should handle JSON Pointer encoding (~1 for / and ~0 for ~)', () => {
318+
const schema: any = {
319+
type: 'object',
320+
properties: {
321+
item: { $ref: '#/$defs/Type~1With~1Slashes' },
322+
},
323+
$defs: {
324+
'Type/With/Slashes': {
325+
type: 'string',
326+
title: 'Encoded Type',
327+
},
328+
},
329+
};
330+
331+
const result = dereferenceJsonSchema(schema) as any;
332+
expect(result.properties.item.title).toBe('Encoded Type');
333+
expect(result.properties.item.type).toBe('string');
334+
});
335+
});

0 commit comments

Comments
 (0)