apikana
Version:
Integrated tools for REST API design - アピ
614 lines (560 loc) • 21.7 kB
JavaScript
;"use strict";
exports.createPathV3Generator = createPathV3Generator;
// Private ////////////////////////////////////////////////////////////////////
const Stream = require( "stream" );
const JavaGen = require('../java-gen');
const Log = require('../log');
const StreamUtils = require('../util/stream-utils');
const UrlUtils = require('../url-utils');
const DEBUG = (process.env.DEBUG !== undefined);
function noop(){}
/**
* @param [options={}]
* @param options.openApi {object}
* The open api spec for the api where we want to build the paths class
* for.
* @param options.openApi.info.title {string}
* Required because this will be used to generate the name of the
* resulting class.
* @param options.javaPackage {string}
* The java package where the generated class will reside in.
* @param options.pathPrefix
* The common path prefix which will be implicitly available without need
* to specify them explicitly.
* @return {{readable: createReadable}}
* A readable streaming the generated class.
*/
function createPathV3Generator( options ) {
if( !options ) options = {};
throwIfPathV3GeneratorOptionsBad( options );
const openApi = options.openApi;
const javaPackage = options.javaPackage;
const pathPrefix = options.pathPrefix;
options = null;
return {
"readable": createReadable,
};
function createReadable(){
try{
// Evaluation of apiName simply copy-pasted from 2ndGen path generator.
const rootClassName = JavaGen.classOf((openApi.info || {}).title || '');
const paths = (openApi.paths || {});
const rootNode = transformPathsToTree( paths );
throwIfTreeWouldProduceNameConflict( rootNode );
const firstNodeAfterBasePath = shiftAwayBasePath( rootNode , pathPrefix );
const fileBeginReadable = StreamUtils.streamFromString( "package "+ javaPackage +";\n\n" );
const rootClass = createClass( rootClassName , firstNodeAfterBasePath , pathPrefix );
return StreamUtils.streamConcat([
fileBeginReadable,
rootClass.readable()
]);
}catch( e ){
return StreamUtils.streamFromError( e );
}
}
}
function throwIfPathV3GeneratorOptionsBad( options ){
if( !options.openApi ) throw Error("Arg 'options.openApi' missing.");
if( !options.openApi.info ) throw Error("Arg 'options.openApi.info' missing.");
if( !options.openApi.info.title ) throw Error("Arg 'options.openApi.info.title' missing.");
if( !options.javaPackage ) throw Error("Arg 'options.javaPackage' missing.");
if( typeof(options.javaPackage) !== "string" ) throw Error( "Arg 'options.javaPackage' string expected but got '"+typeof(options.javaPackage)+"'" );
if( !/^(?![0-9])(?!.*\.[0-9])[A-Za-z0-9.]+$/.test(options.javaPackage) ) throw Error( "Illegal chars in javaPackage" );
if( !options.pathPrefix ){
Log.debug("'options.pathPrefix' not set. Assume empty.");
options.pathPrefix = "";
}
}
/**
* @param [options={}]
* @param options.path {string}
* The path this resource will contain as its value.
* @return {{readable: (function())}}
*/
function createResourceField( options ){
if( !options ) options = {};
if( DEBUG ){
if( !options.path ) throw Error( "Arg 'options.path' missing." );
}
const path = options.path;
options = null;
// Ensure fail-fast in case we would produce invalid slashing somehow. A
// resource path always has to:
// 1. Start with a slash,
// 2. End without a slash,
// 3. Doesn't contain empty segments (aka double slash).
if( /^[^\/]|\/$|\/\//.test(path) ){
throw Error("Fail-fast because proceeding would produce corrupt path '"+path+"'. May this is an issue in the openapi file. But this also could be a bug in apikana.");
}
return {
readable: createReadable,
};
function createReadable(){
const resourceField = createJavaVariable({
access: "public",
isStatic: true,
isFinal: true,
type: "String",
name: "RESOURCE",
value: '"'+ path +'"',
});
return resourceField.readable();
}
}
function createCollectionField() {
return {
readable: createReadable,
};
function createReadable(){
const collectionField = createJavaVariable({
access: "public",
isStatic: true,
isFinal: true,
type: "String",
name: "COLLECTION",
value: 'RESOURCE + "/"',
});
return collectionField.readable();
}
}
/**
* @param name {string}
* Name of the class to generate.
* @param node {Map.<string,node>}
* The tree node to generate the class for.
* @param pathPrefix {string}
* Common base of all paths. Those segments will not be available in
* generated classes. Instead, first available segment will be segment
* after that specified base.
* @return {{readable:function(){}}
* An object where method 'readable' will return a new readable which
* will stream the generated class.
*/
function createClass( name , node , pathPrefix ){
if( DEBUG ){
if( !name ) throw Error("Arg 'name' expected not to be falsy");
}
// Extract hidden args (recursion state).
const segmentStack = Array.isArray(arguments[3]) ? arguments[3] : [];
/** 'null' when not based or integer offset instead. */
const baseOffset = (!isNaN(arguments[4]) ? arguments[4] : null);
// Normalize pathPrefix to have leading slash only.
pathPrefix = UrlUtils.dropSurroundingSlashes( pathPrefix );
if( pathPrefix !== "" ){
pathPrefix = '/'+ pathPrefix;
}
const thisClassName = mangleNameToDifferFromEarlierEqualSegments( segmentToConstantName(name) , segmentStack.slice(0,segmentStack.length-1) );
// Setup constructor
const ctorReadable = StreamUtils.streamFromString( "private "+ thisClassName +"(){}\n" );
// Setup constants.
// 'slice' only when offset is required (baseOffset not null).
var resourceFieldPath = (isNaN(baseOffset)?segmentStack:segmentStack.slice(baseOffset)).join('/');
if( resourceFieldPath !== "" ){ // Prefix with slash if not empty.
resourceFieldPath = '/'+ resourceFieldPath;
}
if( baseOffset===null ){
// Not based. Therefore also prefix with full pathPrefix.
resourceFieldPath = pathPrefix + resourceFieldPath;
}
var resourceField;
var collectionField;
if( resourceFieldPath === '' ){
// Don't generate them if they would be empty.
resourceField = collectionField = {
readable: StreamUtils.emptyStream.bind(0),
};
}else{
resourceField = createResourceField({
path: resourceFieldPath,
});
collectionField = createCollectionField();
}
// Setup BASED class in case we're not already based.
var basedClass;
if( baseOffset === null ){
// Not based yet. Enter BASED now.
const bodyForBased = [];
Object.keys( node ).forEach(function( segment ){
const childClass = createClass( segment , node[segment] , pathPrefix , segmentStack.concat([segment]) , segmentStack.length ); // Go recursive here.
bodyForBased.push( childClass );
});
basedClass = createJavaCustomType({
name: "BASED",
isStatic: true,
isFinal: true,
bodyReadable: StreamUtils.streamConcat( bodyForBased.map(e=>e.readable()) ),
});
}else{
// Already based. Don't generate nested 'BASED' classes.
basedClass = {
readable: StreamUtils.emptyStream.bind(0),
};
}
// Setup child classes.
const childClassReadables = [];
Object.keys( node ).forEach(function( segment ){
const childClass = createClass( segment , node[segment] , pathPrefix , segmentStack.concat([segment]) , baseOffset ); // Go recursive here.
childClassReadables.push( childClass.readable() );
});
// Compose this class from above parts.
const thisClass = createJavaCustomType({
name: thisClassName,
isStatic: segmentStack.length > 0,
isFinal: true,
bodyReadable: StreamUtils.streamConcat([
ctorReadable,
resourceField.readable(),
collectionField.readable(),
StreamUtils.streamConcat( childClassReadables ),
basedClass.readable(),
]),
});
return thisClass;
}
/**
* <p>Creates a custom java type (class, interface, ...)</p>
*
* @param options.name {string}
* Name of the type.
* @param [options.access=public] {string}
* Access modifier for type to geenrate. One of '', 'public', 'protected',
* 'private'.
* @param [options.isStatic=false] {boolean}
* @param [options.isFinal=false] {boolean}
* @param [options.type=class] {string}
* Either 'class', 'interface' or 'enum'.
* @param [options.isAbstract=false] {boolean}
* @param [options.bodyReadable=null] {Readable}
* If falsy, body of type will be empty.
* @return {object}
* obj.readable(void) - Returns new readable which will stream that
* instance in serialized form.
*/
function createJavaCustomType( options ){
if( !options ) options = {};
if( DEBUG ){ // Validate args.
if( !options.name ){ throw Error( "Arg 'options.name' missing." ); }
if( typeof(options.bodyReadable)==="undefined" ) throw Error( "Arg 'options.bodyReadable' missing." );
if( options.type && ["class","interface","enum"].indexOf(options.type) === -1 ) throw Error("Illegal type '"+ options.type +"'.");
if( options.access && ["","public","protected","private"].indexOf(options.access) === -1 ) throw Error("Arg 'options.access': Illegal value '"+ options.access +"'.");
}
const access = (options.access ? options.access : "public");
const isStatic = !!options.isStatic;
const isFinal = !!options.isFinal;
const type = options.type || "class";
const isAbstract = !!options.isAbstract;
const typeName = options.name;
const bodyReadable = options.bodyReadable;
options = null;
const indent = " ";
return {
readable: createReadable,
};
function createReadable(){
const that = new Stream.Readable({ read:noop });
// Start of type.
that.push( access ); // TODO: Prevent space in case 'access' is empty (package private).
if( isStatic ){ that.push(" static"); }
if( isFinal ){ that.push(" final"); }
if( isAbstract ){ that.push(" abstract"); }
that.push( " "+ type +" "+ typeName +" {\n" );
// Inject body from passed in stream.
if( bodyReadable ){
bodyReadable
// Use filter to indent body.
.pipe( StreamUtils.createLinePrefixStream({ prefix:indent }) )
.on( "data" , that.push.bind(that) )
.on( "error" , that.emit.bind(that,"error") )
.on( "end" , onBodyWritten )
;
}else{
onBodyWritten();
}
function onBodyWritten(){
// End of type and end of our stream.
that.push( "}\n" );
that.push( null );
}
return that;
}
}
/**
* @param options.type {string}
* Type of the variable.
* @param options.name {string}
* Name for the variable.
* @param [options.access=""] {""|"public"|"protected"|"private"}
* @param [options.isStatic=false] {boolean}
* @param [options.isFinal=false] {boolean}
* @param [options.value=null] {string}
* Value to assign to the variable. If falsy, no assignment is generated.
*/
function createJavaVariable( options ) {
if( DEBUG ){ // Check args.
if( options.access && ["","public","protected","private"].indexOf(options.access)===-1 ){ debugger; throw Error( "Illegal access modifier '"+options.access+"'." ); }
if( !/^[A-Za-z0-9_$][A-Za-z0-9_$]*$/.test(options.type) ) throw Error("Illegal type '"+options.type+"'");
}
const access = options.access ? options.access : "";
const isStatic = !!options.isStatic;
const isFinal = !!options.isFinal;
const type = options.type;
const name = options.name;
const value = options.value;
options = null;
return {
readable: createReadable
};
function createReadable(){
const that = new Stream.Readable({ read:noop });
var begun = false;
if( access.length>0 ){
that.push( access );
begun = true;
}
if( isStatic ){
if( begun ){ that.push(" "); }
that.push( "static" );
begun = true;
}
if( isFinal ){
if( begun ){ that.push(" "); }
that.push( "final" );
begun = true;
}
if( begun ){ that.push(" "); }
that.push( type +" "+ name );
if( value ){
that.push( " = "+ value );
}
that.push( ";\n" );
that.push( null );
return that;
}
}
/**
* @param paths {Map<String,any>}
* The paths to transform. Actually this methods uses the keys of the
* passed map as paths.
* @return {Map<string,Map<any>>}
* A tree representing the passed in paths.
* @throws Error
* In case there's something wrong with slashes. Eg: leading slash
* missing.
*/
function transformPathsToTree( paths ){
const segments2d = splitAllPathsToArrays( paths );
// Drop leading segment and ensure it was empty.
for( var i=0 ; i<segments2d.length ; ++i ){
const firstSegment = segments2d[i].splice(0,1)[0];
if( firstSegment !== "" ){
throw Error( "Leading slash missing on path '"+firstSegment+'/'+segments2d[i].join('/')+"'" );
}
}
const rootNode = arrange2dSegmentsAsTree( segments2d );
return rootNode;
}
/**
* @param node
* The node to shift from.
* @param pathPrefix {string}
* Path to remove from specified rootNode.
* @return
* Node representing latest segment present in specified pathPrefix.
* @throws Error
* In case there is a path which doesn't fit into path-prefix.
*/
function shiftAwayBasePath( node , pathPrefix ){
pathPrefix = UrlUtils.dropSurroundingSlashes( pathPrefix );
const segmentStack = [];
if( !pathPrefix || pathPrefix.length === 0 ){
return node;
}
pathPrefix = pathPrefix.split('/');
for( let i=0 ; i<pathPrefix.length ; ++i ){
const key = pathPrefix[i];
const actualKeys = Object.keys( node );
if( actualKeys.length > 1 || actualKeys[0] !== key ){
// Error: We either found multiple segments for this level or the only segment
// doesn't match path-prefix.
throwSegmentMismatchError( actualKeys , key );
}
segmentStack.push( key );
node = node[key]; // Shift down one step.
}
return node;
function throwSegmentMismatchError( actualSegments , pathPrefixSegment ){
// Find first mismatching segment.
var badKey = null;
for( let i=0 ; i<actualSegments.length ; ++i ){
if( actualSegments[i] !== pathPrefixSegment ){
badKey = actualSegments[i];
break;
}
}
// Start full path by available stack.
var fullPath = '/'+ segmentStack.join('/');
// Append the current segment.
if( !fullPath.endsWith('/') ){ fullPath+='/'; }
if(badKey){
fullPath += badKey;
// Also append the later segments we've not iterated yet. (If there are some available).
for( let it=node[badKey],subKey ; subKey=Object.keys(it)[0] ; it=it[subKey] ){
fullPath += '/'+ subKey;
}
}
throw Error( "Path '"+fullPath+"' doesn't fit into path-prefix '/"+pathPrefix.join('/')+"/'" );
}
}
/**
* <p>Takes an array of paths and splits them all to segments.</p>
*
* @param paths {Map<String,String>}
* @return {Array<Array<string>>}
*/
function splitAllPathsToArrays( paths ){
const keys = Object.keys( paths );
if( keys.length === 0 ){
Log.debug( "No paths specified." );
}
const result = [];
for( var i=0 ; i<keys.length ; ++i ){
result[i] = keys[i].split( "/" );
}
return result;
}
/**
* @param paths {Array<Array<string>>}
* Array of array of path-segments to transform into a tree.
* @return {Map<Map<...>>}
* Nested objects where the property names represent a path segment. Aka
* tree structure.
*/
function arrange2dSegmentsAsTree( paths ){
const rootNode = {};
for( let i=0 ; i<paths.length ; ++i ){
mergeArrayIntoTree( rootNode , paths[i] );
}
return rootNode;
function mergeArrayIntoTree( node , arr ) {
for( let i=0 ; i<arr.length ; ++i ){
const segment = arr[i];
if( segment==="" ){
if( i===arr.length-1 ){
throw Error( "Path '/"+arr.join('/')+"' MUST NOT end with an empty segment (aka slash at end)." );
}else{
throw Error( "Path '/"+arr.join('/')+"' MUST NOT contain empty path segments (aka double slashes)." );
}
}
if( !node[segment] ){
node[segment] = {};
}
// Shift iterator down one step.
node = node[segment];
}
}
}
/**
* @param segment {string}
* Path segment.
* @return {string}
* Java identifier for specified path segment.
*/
function segmentToConstantName( segment ) {
var ans;
if( pathSegmentIsVariable(segment) ){
// Drop surrounding curly braces.
ans = segment.substr( 1 , segment.length-2 );
// Add a dollar sign
ans += '$';
}else{
// Use segment as is.
ans = segment;
}
ans = escapeForJavaIdentifier( ans );
return ans;
}
/**
* <p>This will append an underscore to specified className everytime className
* already is used earlier to prevent name conflicts.</p>
*
* @param className {string}
* The identifier we want to use as our class name.
* @param segmentStack {Array<string>}
* Segments, which are in path before our current segment.
* @return {string}
* The mangled className.
*/
function mangleNameToDifferFromEarlierEqualSegments( className , segmentStack ){
let suffix = "";
for( let i=0,iLen=segmentStack.length ; i<iLen ; ++i ){
if( segmentToConstantName(segmentStack[i]) === className ){
// Append one more
suffix += '_';
}
}
if( suffix.length > 0 ){
className += suffix;
}
return className;
}
function pathSegmentIsVariable( segment ) {
return /^{.*}$/.test( segment );
}
/**
* @param str {string}
* The value to escape.
* @return {string}
* Passed in value where all special chars are replaced by an underscore
* char. Also reserved words will get a leading underscore to prevent name
* problems.
*/
function escapeForJavaIdentifier( str ){
if( DEBUG ){
if( typeof(str)!=="string" ){ debugger; throw Error("Arg 'str' expected to be string."); }
}
// Copied from "https://www.thoughtco.com/reserved-words-in-java-2034200".
const reservedWords = [
"abstract", "assert", "boolean", "break", "byte", "case", "catch", "char",
"class", "const", "continue", "default", "double", "do", "else", "enum",
"extends", "false", "final", "finally", "float", "for", "goto", "if",
"implements", "import", "instanceof", "int", "interface", "long", "native",
"new", "null", "package", "private", "protected", "public", "return",
"short", "static", "strictfp", "super", "switch", "synchronized", "this",
"throw", "throws", "transient", "true", "try", "void", "volatile", "while"
];
// Replace every char thats not inside A-Z, a-z, 0-9 underscore or dollar
// sign. (Yes: There are several more valid chars we shouldn't replace. But
// its much simpler to quick-n-dirty replace these too)
str = str.replace( /(^[0-9]|[^A-Za-z0-9_$])/g , '_' );
if( reservedWords.indexOf(str) !== -1 ){
str = '_'+ str;
}
return str;
}
/**
* @param node
* The tree node to validate. This is a tree simply consisting of nested Maps.
* @throws {Error}
* In case tree would produce name collisions when generated.
*/
function throwIfTreeWouldProduceNameConflict( node ){
const segmentStack = arguments[1] ? arguments[1] : [];
const keys = Object.keys( node );
const rawNamesOnThisLayer = [];
const namesOnThisLayer = [];
for( let i=0 ; i<keys.length ; ++i ){
const key = keys[i];
const child = node[key];
const childName = segmentToConstantName( key );
throwIfTreeWouldProduceNameConflict( child , segmentStack.concat([childName]) ); // Traverse recursively.
const idx = namesOnThisLayer.indexOf(childName);
if( idx !== -1 ){
// Name already seen earlier.
throw Error( "Path segment '"+key+"' collides with '"+rawNamesOnThisLayer[idx]+"'. Both of them would result in '/"+segmentStack.concat([childName]).join('/')+"'." );
}else{
// Name not used yet
rawNamesOnThisLayer.push( key );
namesOnThisLayer.push( childName );
}
}
}