Skip to content

Commit

Permalink
feat: introduce a basic support for etags (#828)
Browse files Browse the repository at this point in the history
* feat: introduce a basic support for etags

* chore: fix tests

* chore: fix sonar

* Update packages/ui5-middleware-fe-mockserver/src/configResolver.ts

Co-authored-by: Dirk Teufel <50441133+drktfl@users.noreply.github.com>

---------

Co-authored-by: Dirk Teufel <50441133+drktfl@users.noreply.github.com>
  • Loading branch information
nlunets and drktfl committed Jul 2, 2024
1 parent cb5ecc7 commit b215858
Show file tree
Hide file tree
Showing 11 changed files with 129 additions and 18 deletions.
6 changes: 6 additions & 0 deletions .changeset/brave-lizards-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sap-ux/ui5-middleware-fe-mockserver': patch
'@sap-ux/fe-mockserver-core': patch
---

feat: introduce a basic support for entity ETags
3 changes: 3 additions & 0 deletions packages/fe-mockserver-core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface ConfigService {
cdsServiceName?: string;
debug: boolean;
noETag: boolean;
validateETag: boolean;
contextBasedIsolation: boolean;
forceNullableValuesToNull: boolean;
strictKeyMode: boolean;
Expand All @@ -50,6 +51,7 @@ export interface BaseServerConfig {
debug?: boolean;
watch?: boolean;
noETag?: boolean;
validateETag?: boolean;
contextBasedIsolation?: boolean;
generateMockData?: boolean;
forceNullableValuesToNull?: boolean;
Expand Down Expand Up @@ -85,6 +87,7 @@ export type ServiceConfig = {
watch?: boolean; // should be forced to false in browser
noETag?: boolean; // should be forced to true in browser
contextBasedIsolation?: boolean;
validateETag?: boolean;
};

export type ServiceConfigEx = ServiceConfig & {
Expand Down
2 changes: 2 additions & 0 deletions packages/fe-mockserver-core/src/data/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ export interface EntitySetInterface {
getEntityInterface(entitySetName: string, tenantId: string): Promise<FileBasedMockData | undefined>;
getMockData(tenantId: string): FileBasedMockData;
isV4(): boolean;
shouldValidateETag(): boolean;
isDraft(): boolean;
}
export interface DataAccessInterface {
isV4(): boolean;
shouldValidateETag(): boolean;
getNavigationPropertyKeys(
data: any,
navPropDetail: NavigationProperty,
Expand Down
16 changes: 16 additions & 0 deletions packages/fe-mockserver-core/src/data/dataAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export class DataAccess implements DataAccessInterface {
public log: ILogger;
protected readonly strictKeyMode: boolean;
protected readonly contextBasedIsolation: boolean;
protected readonly validateETag: boolean;
protected entitySets: Record<string, MockDataEntitySet> = {};
protected stickyEntitySets: StickyMockEntitySet[] = [];
protected generateMockData: boolean;
Expand All @@ -74,6 +75,7 @@ export class DataAccess implements DataAccessInterface {
this.generateMockData = !!service.generateMockData;
this.forceNullableValuesToNull = !!service.forceNullableValuesToNull;
this.contextBasedIsolation = !!service.contextBasedIsolation;
this.validateETag = !!service.validateETag;
this.fileLoader = fileLoader;
if (this.generateMockData) {
this.log.info('Missing mockdata will be generated');
Expand All @@ -85,6 +87,10 @@ export class DataAccess implements DataAccessInterface {
return this.metadata.getVersion() !== '2.0';
}

public shouldValidateETag(): boolean {
return this.validateETag;
}

private initializeMockData() {
// Preload the mock entityset asynchronously
this.metadata.getEntitySets().forEach((entitySet) => {
Expand Down Expand Up @@ -513,6 +519,9 @@ export class DataAccess implements DataAccessInterface {

for (const record of dataArray) {
// navigation properties not part of $expand --> delete
if (!this.validateETag) {
delete record['@odata.etag'];
}
Object.keys(record)
.filter(
(property) =>
Expand All @@ -523,6 +532,10 @@ export class DataAccess implements DataAccessInterface {
}
} else {
// explicit $select
if (this.validateETag) {
selectDefinition['@odata.etag'] = this.validateETag;
}

for (const record of dataArray) {
// neither part of $select nor a key property --> delete
Object.keys(record)
Expand Down Expand Up @@ -801,6 +814,9 @@ export class DataAccess implements DataAccessInterface {
if (!dontClone) {
data = cloneDeep(data);
}
if (this.validateETag && !Array.isArray(data) && mockEntitySet.isDraft()) {
odataRequest.setETag(data['@odata.etag']);
}

this.applySelect(data, odataRequest.selectedProperties, odataRequest.expandProperties, currentEntityType);

Expand Down
4 changes: 4 additions & 0 deletions packages/fe-mockserver-core/src/data/entitySets/entitySet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ export class MockDataEntitySet implements EntitySetInterface {
return this.dataAccess.isV4();
}

public shouldValidateETag(): boolean {
return this.dataAccess.shouldValidateETag();
}

public isDraft(): boolean {
return !!(
this.entitySetDefinition?.annotations.Common?.DraftRoot ??
Expand Down
65 changes: 50 additions & 15 deletions packages/fe-mockserver-core/src/mockdata/fileBasedMockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { PathAnnotationExpression } from '@sap-ux/vocabularies-types/Edm';
import type { RecursiveHierarchy } from '@sap-ux/vocabularies-types/vocabularies/Aggregation';
import cloneDeep from 'lodash.clonedeep';
import type { EntitySetInterface, PartialReferentialConstraint } from '../data/common';
import { generateId, getData, setData, uuidv4 } from '../data/common';
import { ExecutionError, generateId, getData, setData, uuidv4, _getDateTimeOffset } from '../data/common';
import type { AncestorDescendantsParameters, TopLevelParameters } from '../request/applyParser';
import type ODataRequest from '../request/odataRequest';
import type { KeyDefinitions } from '../request/odataRequest';
Expand Down Expand Up @@ -118,6 +118,9 @@ function compareRowData(
}
return getData(row1, hierarchyNodeProperty) === getData(row2, hierarchySourceProperty);
}
/**
*
*/
export class FileBasedMockData {
protected _mockData: object[];
protected _keyIndex: Record<string, Record<string, number> | false> = {};
Expand All @@ -143,14 +146,14 @@ export class FileBasedMockData {
if (this._mockData.forEach) {
this._mockData.forEach((mockLine: any) => {
// We need to ensure that complex types are at least partially created
this.validateProperties(mockLine, this._entityType.entityProperties);
this.validateProperties(mockLine, this._entityType.entityProperties, true);
});
}
this.cleanupHierarchies();
}
}

private validateProperties(mockEntry: any, properties: Property[]) {
private validateProperties(mockEntry: any, properties: Property[], topLevel: boolean = false) {
properties.forEach((prop) => {
if (
(!this.__forceNullableValuesToNull || prop.nullable === false) &&
Expand All @@ -176,6 +179,12 @@ export class FileBasedMockData {
}
}
});
if (this._mockDataEntitySet?.shouldValidateETag?.()) {
const currentDate = _getDateTimeOffset(true);
if (topLevel) {
mockEntry['@odata.etag'] = `W/\\"${currentDate}\\"`;
}
}
}
public cleanupHierarchies() {
const allAggregations = this._entityType.annotations?.Aggregation ?? {};
Expand Down Expand Up @@ -228,11 +237,27 @@ export class FileBasedMockData {

async updateEntry(
keyValues: KeyDefinitions,
updatedData: object,
updatedData: any,
_patchData: object,
_odataRequest: ODataRequest
): Promise<void> {
if (
this._mockDataEntitySet.shouldValidateETag?.() &&
_odataRequest.etagReference !== updatedData['@odata.etag']
) {
throw new ExecutionError(
'ETag condition not met',
412,
{ code: 412001, message: 'ETag condition not met' },
false
);
}
const dataIndex = this.getDataIndex(keyValues, _odataRequest);
const currentDate = _getDateTimeOffset(true);
if (this._mockDataEntitySet?.shouldValidateETag?.()) {
updatedData['@odata.etag'] = `W/\\"${currentDate}\\"`;
}

this._mockData[dataIndex] = updatedData;
this.createKeyIndex();
}
Expand Down Expand Up @@ -337,6 +362,11 @@ export class FileBasedMockData {
};
}

/**
*
* @param keyValues
* @param _odataRequest
*/
async removeEntry(keyValues: KeyDefinitions, _odataRequest: ODataRequest): Promise<void> {
const dataIndex = this.getDataIndex(keyValues, _odataRequest);

Expand Down Expand Up @@ -565,7 +595,10 @@ export class FileBasedMockData {
outObj[navigationProperty.name] = [];
}
});

if (this._mockDataEntitySet?.shouldValidateETag?.()) {
const currentDate = _getDateTimeOffset(true);
outObj['@odata.etag'] = `W/\\"${currentDate}\\"`;
}
return outObj;
}

Expand Down Expand Up @@ -707,10 +740,8 @@ export class FileBasedMockData {
}
return literalValue;
});
} else {
if (literal.startsWith("datetime'")) {
targetDateLiteral = literal.substring(9, literal.length - 1);
}
} else if (literal.startsWith("datetime'")) {
targetDateLiteral = literal.substring(9, literal.length - 1);
}
}

Expand All @@ -733,12 +764,10 @@ export class FileBasedMockData {
return literalValue;
}
});
} else {
if (literal.startsWith("guid'")) {
targetLiteral = literal.substring(5, literal.length - 1);
} else if (literal.startsWith("'")) {
targetLiteral = literal.substring(1, literal.length - 1);
}
} else if (literal.startsWith("guid'")) {
targetLiteral = literal.substring(5, literal.length - 1);
} else if (literal.startsWith("'")) {
targetLiteral = literal.substring(1, literal.length - 1);
}
}

Expand Down Expand Up @@ -1201,7 +1230,13 @@ export class FileBasedMockData {
}
}

/**
*
*/
getHierarchyDefinition(hierarchyQualifier: string): HierarchyDefinition;
/**
*
*/
getHierarchyDefinition(
hierarchyQualifier: string,
excludeSourceRef: true
Expand Down
13 changes: 13 additions & 0 deletions packages/fe-mockserver-core/src/request/odataRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function addPathToExpandParameters(
export default class ODataRequest {
private isMinimalRepresentation: boolean;
public isStrictMode: boolean;
public etagReference?: string;
public tenantId: string;
public queryPath: QueryPath[];
public searchQuery: string[];
Expand All @@ -98,6 +99,7 @@ export default class ODataRequest {
private allParams: URLSearchParams;
private context: string;
private messages: any[] = [];
private elementETag: string | undefined;

constructor(private requestContent: ODataRequestContent, private dataAccess: DataAccess) {
const parsedUrl = new URL(`http://dummy${requestContent.url}`);
Expand All @@ -108,6 +110,7 @@ export default class ODataRequest {
this.addResponseHeader('sap-tenantid', this.tenantId);
}
this.isMinimalRepresentation = requestContent.headers?.['prefer'] === 'return=minimal';
this.etagReference = requestContent.headers?.['if-match'];
this.isStrictMode = requestContent.headers?.['prefer']?.includes('handling=strict') ?? false;
this.queryPath = this.parsePath(parsedUrl.pathname.substring(1));
this.parseParameters(parsedUrl.searchParams);
Expand Down Expand Up @@ -485,6 +488,16 @@ export default class ODataRequest {
this.context = context;
}

public setETag(etagValue: string | undefined) {
this.elementETag = etagValue;
}

public getETag(): string | undefined {
if (this.dataAccess.shouldValidateETag()) {
return this.elementETag;
}
}

public getResponseData() {
if (this.messages.length) {
this.addResponseHeader('sap-messages', JSON.stringify(this.messages));
Expand Down
3 changes: 3 additions & 0 deletions packages/fe-mockserver-core/src/router/batchRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ async function handlePart(
if (partDefinition.contentId) {
batchResponse += `Content-ID: ${partDefinition.contentId}${NL}`;
}
if (partRequest.getETag()) {
batchResponse += `ETag: ${partRequest.getETag()}${NL}`;
}
batchResponse += NL;
const responseData = partRequest.getResponseData();
batchResponse += `HTTP/1.1 ${partRequest.statusCode} ${http.STATUS_CODES[partRequest.statusCode]}${NL}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -677,14 +677,21 @@ exports[`V4 Requestor can update data through a call 2`] = `
`;
exports[`V4 Requestor can update data through a call 3`] = `
{
"code": 412001,
"message": "ETag condition not met",
}
`;
exports[`V4 Requestor can update data through a call 4`] = `
{
"@odata.context": "$metadata#RootElement(ID=556)/Prop1",
"@odata.metadataEtag": "W/"60af-tCc45VlNT7csKPRiK0amTPyY35E"",
"HasActiveEntity": false,
"HasDraftEntity": false,
"ID": 556,
"IsActiveEntity": false,
"Prop1": "Lali-ho",
"Prop1": "Lali-hoho",
"Prop2": "",
"Sibling_ID": 0,
"isBoundAction1Hidden": false,
Expand Down
Loading

0 comments on commit b215858

Please sign in to comment.