ForestAdmin/toolbelt

View on GitHub
src/services/dumpers/forest-express.js

Summary

Maintainability
D
2 days
Test Coverage
A
100%
const { URL } = require('url');
const { plural, singular } = require('pluralize');
const IncompatibleLianaForUpdateError = require('../../errors/dumper/incompatible-liana-for-update-error');
const InvalidForestCLIProjectStructureError = require('../../errors/dumper/invalid-forest-cli-project-structure-error');
const AbstractDumper = require('./abstract-dumper').default;

class ForestExpress extends AbstractDumper {
  templateFolder = 'forest-express';

  constructor(context) {
    super(context);

    const {
      assertPresent,
      env,
      Sequelize,
      Handlebars,
      mkdirp,
      isLinuxOs,
      buildDatabaseUrl,
      isDatabaseLocal,
      toValidPackageName,
      strings,
      lodash,
    } = context;

    assertPresent({
      env,
      Sequelize,
      Handlebars,
      mkdirp,
      isLinuxOs,
      buildDatabaseUrl,
      isDatabaseLocal,
      toValidPackageName,
      strings,
      lodash,
    });

    this.DEFAULT_PORT = 3310;
    this.env = env;
    this.isLinuxOs = isLinuxOs;
    this.Sequelize = Sequelize;
    this.Handlebars = Handlebars;
    this.mkdirp = mkdirp;
    this.lodash = lodash;
    this.buildDatabaseUrl = buildDatabaseUrl;
    this.isDatabaseLocal = isDatabaseLocal;
    this.toValidPackageName = toValidPackageName;
    this.strings = strings;
  }

  static getModelsNameSorted(schema) {
    return Object.keys(schema).sort((a, b) =>
      a.localeCompare(b, undefined, { sensitivity: 'base' }),
    );
  }

  getSafeReferences(references) {
    return references.map(reference => ({
      ...reference,
      ref: this.getModelNameFromTableName(reference.ref),
    }));
  }

  // HACK: If a table name is "sessions" or "stats" the generated routes will conflict with
  //       Forest Admin internal route (session or stats creation).
  static shouldSkipRouteGenerationForModel(modelName) {
    return ['sessions', 'stats'].includes(modelName.toLowerCase());
  }

  writePackageJson(dbDialect, appName) {
    const orm = dbDialect === 'mongodb' ? 'mongoose' : 'sequelize';
    const dependencies = {
      'body-parser': '1.19.0',
      chalk: '~1.1.3',
      'cookie-parser': '1.4.4',
      cors: '2.8.5',
      debug: '~4.0.1',
      dotenv: '~6.1.0',
      express: '~4.17.1',
      'express-jwt': '6.1.2',
      [`forest-express-${orm}`]: '^9.0.0',
      morgan: '1.9.1',
      'require-all': '^3.0.0',
      sequelize: '~6.29.0',
    };

    if (dbDialect) {
      if (dbDialect.includes('postgres')) {
        dependencies.pg = '~8.2.2';
      } else if (dbDialect === 'mysql') {
        dependencies.mysql2 = '~2.2.5';
      } else if (dbDialect === 'mssql') {
        dependencies.tedious = '^15.1.3';
      } else if (dbDialect === 'mongodb') {
        delete dependencies.sequelize;
        dependencies.mongoose = '~5.13.9';
      }
    }

    const pkg = {
      name: this.toValidPackageName(appName),
      version: '0.0.1',
      private: true,
      scripts: { start: 'node ./server.js' },
      dependencies,
    };

    this.writeFile('package.json', `${JSON.stringify(pkg, null, 2)}\n`);
  }

  tableToFilename(table) {
    return this.lodash.kebabCase(table);
  }

  static isLocalUrl(url) {
    return /^http:\/\/(?:localhost|127\.0\.0\.1)$/.test(url);
  }

  getApplicationUrl(appHostname, appPort) {
    const hostUrl = /^https?:\/\//.test(appHostname) ? appHostname : `http://${appHostname}`;

    return ForestExpress.isLocalUrl(hostUrl)
      ? `${hostUrl}:${appPort || this.DEFAULT_PORT}`
      : hostUrl;
  }

  writeDotEnv(config) {
    const port = config.appConfig.appPort || this.DEFAULT_PORT;
    const databaseUrl = this.buildDatabaseUrl(config.dbConfig);
    const context = {
      databaseUrl,
      ssl: config.dbConfig.dbSsl || 'false',
      dbSchema: config.dbConfig.dbSchema,
      hostname: config.appConfig.appHostname,
      port,
      forestEnvSecret: config.forestEnvSecret,
      forestAuthSecret: config.forestAuthSecret,
      hasDockerDatabaseUrl: false,
      applicationUrl: this.getApplicationUrl(config.appConfig.appHostname, port),
    };
    if (!this.isLinuxOs) {
      context.dockerDatabaseUrl = databaseUrl.replace('localhost', 'host.docker.internal');
      context.hasDockerDatabaseUrl = true;
    }
    this.copyHandleBarsTemplate('env.hbs', '.env', context);
  }

  getModelNameFromTableName(table) {
    return this.strings.transformToCamelCaseSafeString(table);
  }

  writeModel(config, table, fields, references, options = {}) {
    const { underscored } = options;
    let modelPath = `models/${this.tableToFilename(table)}.js`;
    if (config.appConfig.useMultiDatabase) {
      modelPath = `models/${config.appConfig.modelsExportPath}/${this.tableToFilename(table)}.js`;
    }

    const fieldsDefinition = fields.map(field => {
      const expectedConventionalColumnName = underscored
        ? this.lodash.snakeCase(field.name)
        : field.name;
      // NOTICE: sequelize considers column name with parenthesis as raw Attributes
      // only set as unconventional name if underscored is true for adding special field attribute
      // and avoid sequelize issues
      const hasParenthesis =
        field.nameColumn && (field.nameColumn.includes('(') || field.nameColumn.includes(')'));
      const nameColumnUnconventional =
        field.nameColumn !== expectedConventionalColumnName ||
        (underscored && (/[1-9]/g.test(field.name) || hasParenthesis));

      return {
        ...field,
        ref: field.ref && this.getModelNameFromTableName(field.ref),
        nameColumnUnconventional,
        hasParenthesis,

        // Only output default value when non-null
        hasSafeDefaultValue: !this.lodash.isNil(field.defaultValue),
        safeDefaultValue:
          field.defaultValue instanceof this.Sequelize.Utils.Literal
            ? `Sequelize.literal('${field.defaultValue.val.replace(/'/g, "\\'")}')`
            : JSON.stringify(field.defaultValue),
      };
    });

    const referencesDefinition = references.map(reference => ({
      ...reference,
      isBelongsToMany: reference.association === 'belongsToMany',
      targetKey: this.lodash.camelCase(reference.targetKey),
      sourceKey: this.lodash.camelCase(reference.sourceKey),
    }));

    this.copyHandleBarsTemplate(
      `models/${config.dbConfig.dbDialect === 'mongodb' ? 'mongo' : 'sequelize'}-model.hbs`,
      modelPath,
      {
        modelName: this.getModelNameFromTableName(table),
        modelVariableName: this.strings.pascalCase(this.strings.transformToSafeString(table)),
        table,
        fields: fieldsDefinition,
        references: referencesDefinition,
        ...options,
        schema: config.dbConfig.dbSchema,
        dialect: config.dbConfig.dbDialect,
        noId: !options.hasIdColumn && !options.hasPrimaryKeys,
      },
    );
  }

  writeRoute(dbDialect, modelName) {
    const routesPath = `routes/${this.tableToFilename(modelName)}.js`;

    const modelNameDasherized = this.lodash.kebabCase(modelName);
    const readableModelName = this.lodash.startCase(modelName);

    this.copyHandleBarsTemplate('routes/route.hbs', routesPath, {
      modelName: this.getModelNameFromTableName(modelName),
      modelNameDasherized,
      modelNameReadablePlural: plural(readableModelName),
      modelNameReadableSingular: singular(readableModelName),
      isMongoDB: dbDialect === 'mongodb',
    });
  }

  writeForestCollection(dbDialect, table) {
    const collectionPath = `forest/${this.tableToFilename(table)}.js`;

    this.copyHandleBarsTemplate('forest/collection.hbs', collectionPath, {
      isMongoDB: dbDialect === 'mongodb',
      table: this.getModelNameFromTableName(table),
    });
  }

  writeAppJs(dbDialect) {
    this.copyHandleBarsTemplate('app.hbs', 'app.js', {
      isMongoDB: dbDialect === 'mongodb',
      forestUrl: this.env.FOREST_SERVER_URL,
    });
  }

  writeModelsIndex(dbDialect) {
    this.copyHandleBarsTemplate('models/index.hbs', 'models/index.js', {
      isMongoDB: dbDialect === 'mongodb',
    });
  }

  writeDatabasesConfig(dbDialect) {
    this.copyHandleBarsTemplate('config/databases.hbs', 'config/databases.js', {
      isMongoDB: dbDialect === 'mongodb',
      isMSSQL: dbDialect === 'mssql',
      isMySQL: dbDialect === 'mysql',
    });
  }

  writeDockerfile() {
    this.copyHandleBarsTemplate('Dockerfile.hbs', 'Dockerfile');
  }

  writeDockerCompose(config) {
    const databaseUrl = `\${${this.isLinuxOs ? 'DATABASE_URL' : 'DOCKER_DATABASE_URL'}}`;
    const forestUrl = this.env.FOREST_URL_IS_DEFAULT
      ? false
      : `\${FOREST_URL-${this.env.FOREST_SERVER_URL}}`;
    let forestExtraHost = false;
    if (forestUrl) {
      try {
        const parsedForestUrl = new URL(this.env.FOREST_SERVER_URL);
        forestExtraHost = parsedForestUrl.hostname;
      } catch (error) {
        throw new Error(`Invalid value for FOREST_SERVER_URL: "${this.env.FOREST_SERVER_URL}"`);
      }
    }
    this.copyHandleBarsTemplate('docker-compose.hbs', 'docker-compose.yml', {
      containerName: this.lodash.snakeCase(config.appConfig.appName),
      databaseUrl,
      dbSchema: config.dbConfig.dbSchema,
      forestExtraHost,
      forestUrl,
      network: this.isLinuxOs && this.isDatabaseLocal(config.dbConfig) ? 'host' : null,
    });
  }

  writeForestAdminMiddleware(dbDialect) {
    this.copyHandleBarsTemplate('middlewares/forestadmin.hbs', 'middlewares/forestadmin.js', {
      isMongoDB: dbDialect === 'mongodb',
    });
  }

  // NOTICE: Generate files in alphabetical order to ensure a nice generation console logs display.
  async createFiles(config, schema) {
    const { isUpdate, useMultiDatabase, modelsExportPath } = config.appConfig;

    await this.mkdirp(`${this.projectPath}/routes`);
    await this.mkdirp(`${this.projectPath}/forest`);
    await this.mkdirp(`${this.projectPath}/models`);

    if (useMultiDatabase) {
      await this.mkdirp(`${this.projectPath}/models/${modelsExportPath}`);
    }

    if (!isUpdate) {
      await this.mkdirp(`${this.projectPath}/config`);
      await this.mkdirp(`${this.projectPath}/public`);
      await this.mkdirp(`${this.projectPath}/views`);
      await this.mkdirp(`${this.projectPath}/middlewares`);
    }

    const modelNames = ForestExpress.getModelsNameSorted(schema);

    if (!isUpdate) this.writeDatabasesConfig(config.dbConfig.dbDialect);

    modelNames.forEach(modelName =>
      this.writeForestCollection(config.dbConfig.dbDialect, modelName),
    );

    if (!isUpdate) {
      this.writeForestAdminMiddleware(config.dbConfig.dbDialect);
      this.copyHandleBarsTemplate('middlewares/welcome.hbs', 'middlewares/welcome.js');
      this.writeModelsIndex(config.dbConfig.dbDialect);
    }

    modelNames.forEach(modelName => {
      const { fields, references, options } = schema[modelName];
      const safeReferences = this.getSafeReferences(references);

      this.writeModel(config, modelName, fields, safeReferences, options);
    });

    if (!isUpdate) this.copyHandleBarsTemplate('public/favicon.png', 'public/favicon.png');

    modelNames.forEach(modelName => {
      // HACK: If a table name is "sessions" or "stats" the generated routes will conflict with
      //       Forest Admin internal route (session or stats creation).
      //       As a workaround, we don't generate the route file.
      // TODO: Remove the if condition, once the routes paths refactored to prevent such conflict.
      if (!ForestExpress.shouldSkipRouteGenerationForModel(modelName)) {
        this.writeRoute(config.dbConfig.dbDialect, modelName);
      }
    });

    if (!isUpdate) {
      this.copyHandleBarsTemplate('views/index.hbs', 'views/index.html');
      this.copyHandleBarsTemplate('dockerignore.hbs', '.dockerignore');
      this.writeDotEnv(config);
      this.copyHandleBarsTemplate('gitignore.hbs', '.gitignore');
      this.writeAppJs(config.dbConfig.dbDialect);
      this.writeDockerCompose(config);
      this.writeDockerfile();
      this.writePackageJson(config.dbConfig.dbDialect, config.appConfig.appName);
      this.copyHandleBarsTemplate('server.hbs', 'server.js');
    }
  }

  checkForestCLIProjectStructure() {
    try {
      if (!this.fs.existsSync('routes')) throw new Error('No "routes" directory.');
      if (!this.fs.existsSync('forest')) throw new Error('No "forest" directory.');
      if (!this.fs.existsSync('models')) throw new Error('No "models“ directory.');
    } catch (error) {
      throw new InvalidForestCLIProjectStructureError(this.projectPath, error);
    }
  }

  checkLianaCompatiblityForUpdate() {
    const packagePath = 'package.json';
    if (!this.fs.existsSync(packagePath))
      throw new IncompatibleLianaForUpdateError(`"${packagePath}" not found in current directory.`);

    const file = this.fs.readFileSync(packagePath, 'utf8');
    const match = /forest-express-\D*((\d+).\d+.\d+)/g.exec(file);

    let lianaMajorVersion = 0;
    if (match) {
      [, , lianaMajorVersion] = match;
    }
    if (Number(lianaMajorVersion) < 7) {
      throw new IncompatibleLianaForUpdateError(
        'Your project is not compatible with the `forest schema:update` command. You need to use an agent version greater than 7.0.0.',
      );
    }
  }

  hasMultipleDatabaseStructure() {
    const files = this.fs.readdirSync('models', { withFileTypes: true });
    return !files.some(file => file.isFile() && file.name !== 'index.js');
  }
}

module.exports = ForestExpress;