blob: 28096fb6b4985567915a1ea4ec533fbff240d015 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const WEB_ROOT = path.resolve(__dirname, '..');
const REPOSITORY_ROOT = path.resolve(WEB_ROOT, '..');
const JAVA_OPERATOR_SOURCE = path.join(
REPOSITORY_ROOT,
'zeppelin-common/src/main/java/org/apache/zeppelin/common/Message.java'
);
const TYPESCRIPT_OPERATOR_SOURCE = path.join(
WEB_ROOT,
'projects/zeppelin-sdk/src/interfaces/message-operator.interface.ts'
);
const DATA_TYPE_MAP_SOURCE = path.join(
WEB_ROOT,
'projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts'
);
const OPERATION_NAME = /^[A-Z][A-Z0-9_]*$/;
const FRONTEND_ONLY_TAG = 'frontendOnly';
function fail(message) {
throw new Error(`Websocket contract check failed: ${message}`);
}
function addOperation(operations, name, description) {
if (!OPERATION_NAME.test(name)) {
fail(`${description} contains invalid operation '${name}'`);
}
if (operations.has(name)) {
fail(`${description} contains duplicate operation ${name}`);
}
operations.add(name);
}
function readJavaEnumBody(source, file) {
const declarationPattern = /^\s*public\s+enum\s+OP\s*\{/gm;
const declarations = [...source.matchAll(declarationPattern)];
if (declarations.length !== 1) {
fail(`Expected exactly one public enum OP in ${file}, found ${declarations.length}`);
}
const declaration = declarations[0];
const openingBrace = declaration.index + declaration[0].lastIndexOf('{');
let state = 'code';
let body = '';
for (let index = openingBrace + 1; index < source.length; index += 1) {
const character = source[index];
const nextCharacter = source[index + 1];
if (state === 'line-comment') {
if (character === '\n') {
state = 'code';
body += '\n';
}
continue;
}
if (state === 'block-comment') {
if (character === '*' && nextCharacter === '/') {
state = 'code';
body += ' ';
index += 1;
}
continue;
}
if (character === '/' && nextCharacter === '/') {
state = 'line-comment';
index += 1;
continue;
}
if (character === '/' && nextCharacter === '*') {
state = 'block-comment';
index += 1;
continue;
}
if (character === '}') {
return body;
}
if (character === '{' || character === '"' || character === "'") {
fail(`${file} enum OP must contain only simple enum constants`);
}
body += character;
}
const detail = state === 'block-comment' ? 'an unclosed block comment' : 'no closing brace';
fail(`Could not parse enum OP in ${file}: ${detail}`);
}
function parseJavaOperations(source, file = 'Message.java') {
let body = readJavaEnumBody(source, file).trim();
if (body.endsWith(';')) {
body = body.slice(0, -1).trimEnd();
}
const entries = body.split(',');
if (entries.at(-1).trim() === '') {
entries.pop();
}
if (entries.length === 0) {
fail(`${file} enum OP must contain at least one operation`);
}
const operations = new Set();
for (const entry of entries) {
const name = entry.trim();
if (name === '') {
fail(`${file} enum OP contains an empty operation`);
}
addOperation(operations, name, `${file} enum OP`);
}
return operations;
}
function parseTypeScript(source, file) {
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
if (sourceFile.parseDiagnostics.length) {
const diagnostics = sourceFile.parseDiagnostics
.map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
.join('; ');
fail(`Could not parse ${file}: ${diagnostics}`);
}
return sourceFile;
}
function findSingleDeclaration(sourceFile, predicate, description) {
const declarations = sourceFile.statements.filter(predicate);
if (declarations.length !== 1) {
fail(`Expected exactly one ${description} in ${sourceFile.fileName}, found ${declarations.length}`);
}
return declarations[0];
}
function jsDocCommentText(comment) {
if (typeof comment === 'string') {
return comment;
}
if (Array.isArray(comment)) {
return comment.map(part => part.text).join('');
}
return '';
}
function parseTypeScriptOperations(source, file = 'message-operator.interface.ts') {
const sourceFile = parseTypeScript(source, file);
const declaration = findSingleDeclaration(
sourceFile,
statement => ts.isEnumDeclaration(statement) && statement.name.text === 'OP',
'enum OP'
);
const wireOperations = new Set();
const frontendOnlyOperations = new Set();
const allOperations = new Set();
for (const member of declaration.members) {
if (!ts.isIdentifier(member.name) || !member.initializer || !ts.isStringLiteral(member.initializer)) {
fail('Every TypeScript OP member must be an identifier with an explicit string literal value');
}
const name = member.name.text;
if (name !== member.initializer.text) {
fail(`OP.${name} must use the wire value '${name}', found '${member.initializer.text}'`);
}
addOperation(allOperations, name, 'TypeScript enum OP');
const frontendOnlyTags = ts.getJSDocTags(member).filter(tag => tag.tagName.text === FRONTEND_ONLY_TAG);
if (frontendOnlyTags.length > 1) {
fail(`OP.${name} has multiple @${FRONTEND_ONLY_TAG} tags`);
}
if (frontendOnlyTags.length === 1) {
if (jsDocCommentText(frontendOnlyTags[0].comment).trim() === '') {
fail(`OP.${name} must explain why it is @${FRONTEND_ONLY_TAG}`);
}
frontendOnlyOperations.add(name);
} else {
wireOperations.add(name);
}
}
return { allOperations, frontendOnlyOperations, wireOperations };
}
function parseDataTypeMapOperations(source, interfaceName, file = 'message-data-type-map.interface.ts') {
const sourceFile = parseTypeScript(source, file);
const declaration = findSingleDeclaration(
sourceFile,
statement => ts.isInterfaceDeclaration(statement) && statement.name.text === interfaceName,
`interface ${interfaceName}`
);
if (declaration.heritageClauses && declaration.heritageClauses.length) {
fail(`interface ${interfaceName} must not use inheritance`);
}
const operations = new Set();
for (const member of declaration.members) {
if (
!ts.isPropertySignature(member) ||
!member.name ||
!ts.isComputedPropertyName(member.name) ||
!ts.isPropertyAccessExpression(member.name.expression) ||
!ts.isIdentifier(member.name.expression.expression) ||
member.name.expression.expression.text !== 'OP' ||
!ts.isIdentifier(member.name.expression.name)
) {
fail(`Every ${interfaceName} member must use the form [OP.NAME]: Type`);
}
addOperation(operations, member.name.expression.name.text, interfaceName);
}
return operations;
}
function difference(left, right) {
return [...left].filter(value => !right.has(value)).sort();
}
function validateContract(javaOperations, typeScriptOperations, sendOperations, receiveOperations) {
const overlap = [...typeScriptOperations.frontendOnlyOperations]
.filter(operation => javaOperations.has(operation))
.sort();
if (overlap.length) {
fail(`Java wire operations cannot be @${FRONTEND_ONLY_TAG}: [${overlap.join(', ')}]`);
}
const missing = difference(javaOperations, typeScriptOperations.wireOperations);
const extra = difference(typeScriptOperations.wireOperations, javaOperations);
if (missing.length || extra.length) {
fail(
`Java Message.OP and TypeScript wire OP differ; ` +
`missing from TypeScript=[${missing.join(', ')}], extra in TypeScript=[${extra.join(', ')}]`
);
}
for (const operation of sendOperations) {
if (typeScriptOperations.frontendOnlyOperations.has(operation)) {
fail(`Frontend-only operation ${operation} cannot be in MessageSendDataTypeMap`);
}
if (!javaOperations.has(operation)) {
fail(`MessageSendDataTypeMap operation ${operation} is not a Java wire operation`);
}
}
for (const operation of receiveOperations) {
if (!typeScriptOperations.allOperations.has(operation)) {
fail(`MessageReceiveDataTypeMap operation ${operation} is not declared in TypeScript enum OP`);
}
}
for (const operation of typeScriptOperations.frontendOnlyOperations) {
if (!receiveOperations.has(operation)) {
fail(`Frontend-only operation ${operation} must be in MessageReceiveDataTypeMap`);
}
}
}
function main() {
const javaOperations = parseJavaOperations(fs.readFileSync(JAVA_OPERATOR_SOURCE, 'utf8'), JAVA_OPERATOR_SOURCE);
const typeScriptOperations = parseTypeScriptOperations(
fs.readFileSync(TYPESCRIPT_OPERATOR_SOURCE, 'utf8'),
TYPESCRIPT_OPERATOR_SOURCE
);
const dataTypeMapSource = fs.readFileSync(DATA_TYPE_MAP_SOURCE, 'utf8');
const sendOperations = parseDataTypeMapOperations(dataTypeMapSource, 'MessageSendDataTypeMap', DATA_TYPE_MAP_SOURCE);
const receiveOperations = parseDataTypeMapOperations(
dataTypeMapSource,
'MessageReceiveDataTypeMap',
DATA_TYPE_MAP_SOURCE
);
validateContract(javaOperations, typeScriptOperations, sendOperations, receiveOperations);
console.log(
`Websocket contract is synchronized: ${javaOperations.size} wire operations, ` +
`${typeScriptOperations.frontendOnlyOperations.size} frontend-only operation(s).`
);
}
module.exports = {
parseDataTypeMapOperations,
parseJavaOperations,
parseTypeScriptOperations,
validateContract
};
if (require.main === module) {
main();
}