diff --git a/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block-addon/request-document-block-addon.component.ts b/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block-addon/request-document-block-addon.component.ts index d59eebb277..453eb185ee 100644 --- a/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block-addon/request-document-block-addon.component.ts +++ b/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block-addon/request-document-block-addon.component.ts @@ -17,6 +17,7 @@ import { DialogService } from 'primeng/dynamicdialog'; import { AbstractUIBlockComponent } from '../models/abstract-ui-block.component'; import { RequestDocumentBlockDialog } from '../request-document-block/dialog/request-document-block-dialog.component'; import { SchemaRulesService } from 'src/app/services/schema-rules.service'; +import { SchemaService } from 'src/app/services/schema.service'; import { prepareVcData } from 'src/app/modules/common/models/prepare-vc-data'; import { PolicyTestAutomationService } from '../../policy-test-automation/policy-test-automation.service'; @@ -94,12 +95,30 @@ export class RequestDocumentBlockAddonComponent private dialogService: DialogService, private router: Router, private changeDetectorRef: ChangeDetectorRef, - private policyTest: PolicyTestAutomationService + private policyTest: PolicyTestAutomationService, + private schemaService: SchemaService, ) { super(policyEngineService, profile, wsService); this.dataForm = this.fb.group({}); } + protected override _onSuccess(data: any) { + // Resolve a schema reference (id, no document) to the full schema before setData. + const schemaRef = data?.schema; + if (schemaRef && !schemaRef.document && schemaRef.id) { + this.loading = true; + this.schemaService.resolveSchemaById(schemaRef.id).subscribe({ + next: (full) => { + data.schema = full; + super._onSuccess(data); + }, + error: () => super._onSuccess(data) + }); + return; + } + super._onSuccess(data); + } + ngOnInit(): void { this.init(); (window as any).__requestLast = this; diff --git a/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/request-document-block.component.ts b/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/request-document-block.component.ts index 5231b76b85..5ae9ccb3c2 100644 --- a/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/request-document-block.component.ts +++ b/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/request-document-block.component.ts @@ -10,6 +10,7 @@ import { AbstractUIBlockComponent } from '../models/abstract-ui-block.component' import { PolicyHelper } from 'src/app/services/policy-helper.service'; import { RequestDocumentBlockDialog } from './dialog/request-document-block-dialog.component'; import { SchemaRulesService } from 'src/app/services/schema-rules.service'; +import { SchemaService } from 'src/app/services/schema.service'; import { audit, finalize, takeUntil } from 'rxjs/operators'; import { interval, Subject, Subscription, firstValueFrom } from 'rxjs'; import { prepareVcData } from 'src/app/modules/common/models/prepare-vc-data'; @@ -151,6 +152,7 @@ export class RequestDocumentBlockComponent private tablePersist: TablePersistenceService, private ipfsService: IPFSService, private policyTest: PolicyTestAutomationService, + private schemaService: SchemaService, ) { super(policyEngineService, profile, wsService); this.dataForm = this.fb.group({}); @@ -209,6 +211,24 @@ export class RequestDocumentBlockComponent } protected override _onSuccess(data: any) { + // A schema reference (id, no document) is resolved to the full schema once + // before the render flow; a full schema is used as-is. + const schemaRef = data?.schema; + if (schemaRef && !schemaRef.document && schemaRef.id) { + this.loading = true; + this.schemaService.resolveSchemaById(schemaRef.id).subscribe({ + next: (full) => { + data.schema = full; + this._applyBlockData(data); + }, + error: () => this._applyBlockData(data) + }); + return; + } + this._applyBlockData(data); + } + + private _applyBlockData(data: any) { this.setData(data); if (this.type === 'dialog') { setTimeout(() => { diff --git a/frontend/src/app/services/schema.service.spec.ts b/frontend/src/app/services/schema.service.spec.ts new file mode 100644 index 0000000000..1117a734f1 --- /dev/null +++ b/frontend/src/app/services/schema.service.spec.ts @@ -0,0 +1,63 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { SchemaService } from './schema.service'; +import { AuthService } from './auth.service'; +import { API_BASE_URL } from './api'; + +const SINGLE_URL = `${API_BASE_URL}/schema`; + +describe('SchemaService single-schema resolution', () => { + let service: SchemaService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + SchemaService, + { provide: AuthService, useValue: {} }, + ], + }); + service = TestBed.inject(SchemaService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('getSchemaById GETs /schema/:id', () => { + service.getSchemaById('s1').subscribe(); + const req = httpMock.expectOne(`${SINGLE_URL}/s1`); + expect(req.request.method).toBe('GET'); + req.flush({ id: 's1' }); + }); + + it('resolveSchemaById de-duplicates concurrent subscribers into a single request', () => { + const results: any[] = []; + service.resolveSchemaById('s1').subscribe((v) => results.push(v)); + service.resolveSchemaById('s1').subscribe((v) => results.push(v)); + const req = httpMock.expectOne(`${SINGLE_URL}/s1`); + req.flush({ id: 's1', document: { x: 1 } }); + expect(results.length).toBe(2); + expect(results[0]).toEqual(results[1]); + }); + + it('serves a cached result without a second request', () => { + service.resolveSchemaById('s1').subscribe(); + httpMock.expectOne(`${SINGLE_URL}/s1`).flush({ id: 's1' }); + let got: any; + service.resolveSchemaById('s1').subscribe((v) => (got = v)); + httpMock.expectNone(`${SINGLE_URL}/s1`); + expect(got).toEqual({ id: 's1' } as any); + }); + + it('evicts a failed fetch so a later call retries', () => { + let err: any; + service.resolveSchemaById('s1').subscribe({ error: (e) => (err = e) }); + httpMock.expectOne(`${SINGLE_URL}/s1`).flush('boom', { status: 500, statusText: 'Server Error' }); + expect(err).toBeTruthy(); + service.resolveSchemaById('s1').subscribe(); + const retry = httpMock.expectOne(`${SINGLE_URL}/s1`); + expect(retry.request.method).toBe('GET'); + retry.flush({ id: 's1' }); + }); +}); diff --git a/frontend/src/app/services/schema.service.ts b/frontend/src/app/services/schema.service.ts index 285df9ef08..68413b421c 100644 --- a/frontend/src/app/services/schema.service.ts +++ b/frontend/src/app/services/schema.service.ts @@ -1,7 +1,7 @@ import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { ISchema, ISchemaDeletionPreview, SchemaCategory, SchemaEntity, SchemaNode } from '@guardian/interfaces'; -import { Observable } from 'rxjs'; +import { Observable, catchError, shareReplay, throwError } from 'rxjs'; import { API_BASE_URL } from './api'; import { AuthService } from './auth.service'; import { headersV2 } from '../constants'; @@ -16,12 +16,36 @@ export class SchemaService { private readonly url: string = `${API_BASE_URL}/schemas`; private readonly singleSchemaUrl: string = `${API_BASE_URL}/schema`; + // Resolved single-schema requests keyed by id, so a block that receives a + // schema reference resolves the full schema once and shares it. + private readonly schemaByIdCache = new Map>(); + constructor( private http: HttpClient, private auth: AuthService, ) { } + public getSchemaById(id: string): Observable { + return this.http.get(`${this.singleSchemaUrl}/${id}`); + } + + // Resolve a full schema by id, cached and de-duplicated; evict on error to retry. + public resolveSchemaById(id: string): Observable { + let cached = this.schemaByIdCache.get(id); + if (!cached) { + cached = this.getSchemaById(id).pipe( + catchError((error) => { + this.schemaByIdCache.delete(id); + return throwError(() => error); + }), + shareReplay(1) + ); + this.schemaByIdCache.set(id, cached); + } + return cached; + } + public static getOptions(filters?: { pageIndex?: number, pageSize?: number | string, diff --git a/policy-service/src/policy-engine/blocks/request-vc-document-block-addon.ts b/policy-service/src/policy-engine/blocks/request-vc-document-block-addon.ts index 29d74a9761..a6c8233086 100644 --- a/policy-service/src/policy-engine/blocks/request-vc-document-block-addon.ts +++ b/policy-service/src/policy-engine/blocks/request-vc-document-block-addon.ts @@ -153,7 +153,14 @@ export class RequestVcDocumentBlockAddon { user.location === LocationType.REMOTE ), ...options, - schema: { ...this._schema, fields: [], conditions: [] }, + // Lightweight schema reference; the client resolves the full schema by id. + schema: { + id: this._schema.id, + iri: this._schema.iri, + uuid: this._schema.uuid, + name: this._schema.name, + version: this._schema.version, + }, }; return data; } diff --git a/policy-service/src/policy-engine/blocks/request-vc-document-block.ts b/policy-service/src/policy-engine/blocks/request-vc-document-block.ts index 336cdcfd13..a79476d70c 100644 --- a/policy-service/src/policy-engine/blocks/request-vc-document-block.ts +++ b/policy-service/src/policy-engine/blocks/request-vc-document-block.ts @@ -146,7 +146,14 @@ export class RequestVcDocumentBlock { ref.actionType === LocationType.REMOTE && user.location === LocationType.REMOTE ), - schema: { ...this._schema, fields: [], conditions: [] }, + // Lightweight schema reference; the client resolves the full schema by id. + schema: { + id: this._schema.id, + iri: this._schema.iri, + uuid: this._schema.uuid, + name: this._schema.name, + version: this._schema.version, + }, presetSchema: options.presetSchema, presetFields: options.presetFields, editType: options.editType || 'new', diff --git a/policy-service/tests/unit-tests/blocks/request-vc-getdata-reference.test.mjs b/policy-service/tests/unit-tests/blocks/request-vc-getdata-reference.test.mjs new file mode 100644 index 0000000000..137ddb784b --- /dev/null +++ b/policy-service/tests/unit-tests/blocks/request-vc-getdata-reference.test.mjs @@ -0,0 +1,77 @@ +import { assert } from 'chai'; +import { RequestVcDocumentBlock } from '../../../dist/policy-engine/blocks/request-vc-document-block.js'; +import { RequestVcDocumentBlockAddon } from '../../../dist/policy-engine/blocks/request-vc-document-block-addon.js'; +import { PolicyComponentsUtils } from '../../../dist/policy-engine/policy-components-utils.js'; + +// getData must ship only a lightweight schema reference, not the heavy +// document + context (the full schema, resolved client-side by id). + +const origGetRef = PolicyComponentsUtils.GetBlockRef; +const origGetOpts = PolicyComponentsUtils.GetBlockUniqueOptionsObject; + +const fullSchema = () => ({ + id: 'schema-db-id', + iri: '#SchemaIri', + uuid: 'schema-uuid', + name: 'Project', + version: '1.0.0', + document: { $id: '#doc', properties: { big: 'x'.repeat(1000) } }, + context: { '@context': { big: 'y'.repeat(1000) } }, + fields: [{ name: 'f' }], + conditions: [{ if: 1 }], +}); + +function assertReference(schema) { + assert.equal(schema.id, 'schema-db-id'); + assert.equal(schema.iri, '#SchemaIri'); + assert.equal(schema.uuid, 'schema-uuid'); + assert.equal(schema.name, 'Project'); + assert.equal(schema.version, '1.0.0'); + assert.notProperty(schema, 'document'); + assert.notProperty(schema, 'context'); + assert.notProperty(schema, 'fields'); + assert.notProperty(schema, 'conditions'); +} + +after(() => { + PolicyComponentsUtils.GetBlockRef = origGetRef; + PolicyComponentsUtils.GetBlockUniqueOptionsObject = origGetOpts; +}); + +describe('RequestVcDocumentBlock runtime — getData schema reference', () => { + it('emits only light schema identifiers, not document/context', async () => { + const block = Object.create(RequestVcDocumentBlock.prototype); + block._schema = fullSchema(); + block._accessMap = new Map(); + block.state = {}; + const ref = { + uuid: 'blk', + blockType: 'requestVcDocumentBlock', + actionType: 'local', + async getSources() { return []; }, + }; + PolicyComponentsUtils.GetBlockRef = () => ref; + PolicyComponentsUtils.GetBlockUniqueOptionsObject = () => ({}); + + const data = await block.getData({ id: 'u1', location: 'local' }); + assertReference(data.schema); + }); +}); + +describe('RequestVcDocumentBlockAddon runtime — getData schema reference', () => { + it('emits only light schema identifiers, not document/context', async () => { + const block = Object.create(RequestVcDocumentBlockAddon.prototype); + block._schema = fullSchema(); + block._accessMap = new Map(); + const ref = { + uuid: 'blk', + blockType: 'requestVcDocumentBlockAddon', + actionType: 'local', + async getOptions() { return {}; }, + }; + PolicyComponentsUtils.GetBlockRef = () => ref; + + const data = await block.getData({ id: 'u1', location: 'local' }); + assertReference(data.schema); + }); +});