This commit is contained in:
pfych 2024-10-12 14:08:09 +11:00
commit 0029086b3f
148 changed files with 19047 additions and 0 deletions

287
commands/add-object/add-object.js Executable file
View file

@ -0,0 +1,287 @@
#!/usr/bin/env node
// Template Fields
// - nameFirst
// - nameCamel
// - nameSnakeUpper
// - nameUpper
// - nameLower
// - nameKebab
// - apiCreateFields "field1, field2, field3"
// - apiUpdateFields "blankId, field1, field2, field3"
// - primaryKey "blankId"
// - seedData {"blankId": "", "field1": ""},{"blankId": "", "field1": ""}
// - mapperFields blankId: data?.blankId, field1: data?.field1
// - typeFields blankId: string; field1: string;
const fs = require('fs');
const readlineSync = require('readline-sync');
const YAML = require('js-yaml');
const functionNames = [
'And',
'Base64',
'Cidr',
'Condition',
'Equals',
'FindInMap',
'GetAtt',
'GetAZs',
'If',
'ImportValue',
'Join',
'Not',
'Or',
'Ref',
'Select',
'Split',
'Sub',
];
class CustomTag {
constructor(type, data) {
this.type = type;
this.data = data;
}
}
function yamlType(name, kind) {
const functionName = ['Ref', 'Condition'].includes(name) ? name : `!${name}`;
return new YAML.Type(`${functionName}`, {
kind,
multi: true,
representName: function (object) {
return object.type;
},
represent: function (object) {
return object.data;
},
instanceOf: CustomTag,
construct: function (data, type) {
return new CustomTag(type, data);
},
});
}
function generateTypes() {
const types = functionNames
.map((functionName) =>
['mapping', 'scalar', 'sequence'].map((kind) =>
yamlType(functionName, kind),
),
)
.flat();
return types;
}
const writeServerlessApiYaml = () => {
const yamlTypes = generateTypes();
const schema = YAML.DEFAULT_SCHEMA.extend(yamlTypes);
const serverlessFile = fs.readFileSync(
`${projectRoot}/packages/api/serverless.yml`,
'utf8',
);
const yamlJson = YAML.load(serverlessFile, { schema: schema });
const filenameName = `${toKebabCase(name.toLowerCase())}`;
const newFunction = `\${file(./src/baseblocks/${filenameName}/${filenameName}-functions.yml)}`;
const newResource = `\${file(./src/baseblocks/${filenameName}/${filenameName}-dynamodb.yml)}`;
if (
yamlJson.functions.find((i) => i === newFunction) ||
yamlJson.resources.find((i) => i === newResource)
) {
console.log('Conflicting resource/function in serverless.yml, not saving.');
return;
}
yamlJson.functions.push(newFunction);
yamlJson.resources.push(newResource);
yamlJson.provider.iam.role.statements[0].Resource.push(
new CustomTag('!Sub', `\${${toCamelCase(name)}Table.Arn}`),
new CustomTag('!Sub', `\${${toCamelCase(name)}Table.Arn}/index/*`),
);
yamlJson.custom['serverless-dynamodb'].seed.local.sources.push({
table: `\${env:APP_NAME}-\${opt:stage}-${filenameName}`,
sources: [`./src/baseblocks/${filenameName}/${filenameName}.seed.json`],
});
const yamlResult = YAML.dump(yamlJson, {
schema,
});
fs.writeFileSync(`${projectRoot}/packages/api/serverless.yml`, yamlResult);
};
const toCamelCase = (str) => {
return str
.replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) {
return index === 0 ? word.toLowerCase() : word.toUpperCase();
})
.replace(/\s+/g, '');
};
const toKebabCase = (str) =>
str &&
str
.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
.map((x) => x.toLowerCase())
.join('-');
const cwd = process.cwd();
// console.log(`Current Working Dir: ${cwd}`);
const projectRoot = cwd.split('/commands')[0];
// console.log(`Project Root: ${projectRoot}`);
const templatePath = `${cwd}/template`;
// console.log(`Template Path: ${templatePath}`);
let name = readlineSync.question('What is the name of the new object? ');
console.log(`Creating new object [${name}]`);
// Support multi words in kebab case, pascal case or snake case
if (name.includes('-')) {
name = name.split('-').join(' ');
} else if (name.includes('_')) {
name = name.split('_').join(' ');
} else {
name = name
.replace(/([A-Z][a-z])/g, ' $1')
.replace(/(\d)/g, ' $1')
.trim();
}
const primaryKey = `${toCamelCase(name)}Id`;
const inputFields = [];
var fieldName = '';
do {
console.log('Current Fields:', [
primaryKey,
...inputFields.map((i) => i.name),
]);
fieldName = readlineSync.question('New field name (or enter to finish): ');
if (fieldName) {
const tsTypes = ['string', 'number', 'boolean', 'any', 'string[]'];
const index = readlineSync.keyInSelect(tsTypes, 'Type?');
const tsType = tsTypes[index];
const isRequired = readlineSync.keyInYN('is field required?');
console.log(
`Added field [${fieldName}${isRequired ? '' : '?'}: ${tsType}]\n`,
);
inputFields.push({
name: fieldName,
tsType: tsType,
isRequired: isRequired,
});
}
} while (fieldName);
const fields = inputFields.map((field) => field.name);
const allFields = [primaryKey, ...fields];
let dataTypeFields = ` ${primaryKey}: string;`;
inputFields.forEach((field) => {
dataTypeFields = `${dataTypeFields}\n ${field.name}${
field.isRequired ? '' : '?'
}: ${field.tsType};`;
});
let dataMapperFields = '';
allFields.forEach((field) => {
dataMapperFields = `${dataMapperFields}\n ${field}: data?.${field},`;
});
const data = {
name,
nameFirst: `${name[0].toUpperCase()}${toCamelCase(name.slice(1))}`,
nameCamel: `${toCamelCase(name)}`,
nameSnakeUpper: `${name.replace(/\s/g, '_').toUpperCase()}`,
nameUpper: `${name.toUpperCase()}`,
nameLower: `${name.toLowerCase()}`,
nameKebab: `${toKebabCase(name.toLowerCase())}`,
apiCreateFields: fields.join(', '),
apiUpdateFields: allFields.join(', '),
primaryKey: `${toCamelCase(name)}Id`,
seedData: ``,
mapperFields: dataMapperFields,
typeFields: dataTypeFields,
};
const renderTemplate = (template) => {
let generatedContent = template;
Object.keys(data).forEach((key) => {
generatedContent = generatedContent.replace(
new RegExp(`{{ ${key} }}`, 'g'),
data[key],
);
});
return generatedContent;
};
const apiOutputPath = `${projectRoot}/packages/api/src/baseblocks/${toKebabCase(
name.toLowerCase(),
)}`;
const filenameName = `${toKebabCase(name.toLowerCase())}`;
const files = [
{
templateFile: `${templatePath}/api/blank.ts`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}.ts`,
},
{
templateFile: `${templatePath}/api/blank.service.ts`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}.service.ts`,
},
{
templateFile: `${templatePath}/api/blank.seed.json`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}.seed.json`,
},
{
templateFile: `${templatePath}/api/blank-functions.yml`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}-functions.yml`,
},
{
templateFile: `${templatePath}/api/blank-dynamodb.yml`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}-dynamodb.yml`,
},
{
templateFile: `${templatePath}/api/blank-api.ts`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}-api.ts`,
},
{
templateFile: `${templatePath}/types/blank.d.ts`,
outputPath: `${projectRoot}/shared/types`,
outputFilename: `${projectRoot}/shared/types/${filenameName}.d.ts`,
},
{
templateFile: `${templatePath}/client-api/blank.ts`,
outputPath: `${projectRoot}/shared/client-api`,
outputFilename: `${projectRoot}/shared/client-api/${filenameName}.ts`,
},
];
const fileOperations = async (file) => {
const templateFileData = fs.readFileSync(file.templateFile).toString();
const result = renderTemplate(templateFileData);
await fs.promises.mkdir(file.outputPath, {
recursive: true,
});
console.log(`Creating ${file.outputFilename}`);
fs.writeFileSync(file.outputFilename, result);
};
(async () => {
console.log('Creating files...');
for (let filePos = 0; filePos < files.length; filePos++) {
const file = files[filePos];
await fileOperations(file);
}
console.log('Updating api serverless.yml');
writeServerlessApiYaml();
console.log('Done!');
})();

View file

@ -0,0 +1,14 @@
{
"name": "@baseline/add-object",
"version": "1.0.0",
"main": "add-object.js",
"scripts": {
"start": "./add-object.js",
"lint": "echo 'No linting'",
"pretty": "npx prettier --write '*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}'"
},
"devDependencies": {
"readline-sync": "1.4.10",
"js-yaml": "4.1.0"
}
}

View file

@ -0,0 +1,102 @@
import { Response } from 'express';
import { {{ nameCamel }}Mapper } from './{{ nameKebab }}';
import { isAdmin } from '../../middleware/is-admin';
import { RequestContext } from '../../util/request-context.type';
import { {{ nameFirst }} } from '@baseline/types/{{ nameKebab }}';
import { getErrorMessage } from '../../util/error-message';
import createApp from '../../util/express-app';
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
import { {{ nameCamel }}Service } from './{{ nameKebab }}.service';
const app = createApp();
// app.use(isAdmin); // All private endpoints require the user to be an admin
export const handler = createAuthenticatedHandler(app);
app.post('/{{ nameKebab }}', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { {{ apiCreateFields }} } = req.body as {{ nameFirst }};
const {{ nameCamel }}Data: Partial<{{ nameFirst }}> = {
{{ apiCreateFields }},
};
const {{ nameCamel }} = await {{ nameCamel }}Service.create({{ nameCamel }}Data);
res.json({{ nameCamel }}Mapper({{ nameCamel }}));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to create {{ nameLower }} ${message}`);
res.status(400).json({ error: 'Failed to create {{ nameLower }}' });
}
},
]);
app.patch('/{{ nameKebab }}', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { {{ apiUpdateFields }} } = req.body as {{ nameFirst }};
const {{ nameCamel }}Data: Partial<{{ nameFirst }}> = {
{{ apiUpdateFields }}
};
const {{ nameCamel }} = await {{ nameCamel }}Service.update({{ nameCamel }}Data);
res.json({{ nameCamel }}Mapper({{ nameCamel }}));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to update {{ nameLower }}: ${message}`);
res.status(400).json({
error: 'Failed to update {{ nameLower }}',
});
}
},
]);
app.delete('/{{ nameKebab }}/:{{ primaryKey }}', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const {{ primaryKey }} = req.params.{{ primaryKey }};
await {{ nameCamel }}Service.delete({{ primaryKey }});
res.status(200);
res.send();
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to delete {{ nameLower }}: ${message}`);
res.status(400).json({
error: 'Failed to delete {{ nameLower }}',
});
}
},
]);
app.get('/{{ nameKebab }}/list', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const {{ nameCamel }}s = await {{ nameCamel }}Service.getAll();
const formatted{{ nameFirst }}s = {{ nameCamel }}s.map({{ nameCamel }}Mapper);
res.json(formatted{{ nameFirst }}s);
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get {{ nameLower }}s: ${message}`);
res.status(400).json({
error: 'Failed to get {{ nameLower }}s',
});
}
},
]);
app.get('/{{ nameKebab }}/:{{ primaryKey }}', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const {{ nameCamel }} = await {{ nameCamel }}Service.get(req.params.{{ primaryKey }});
res.json({{ nameCamel }}Mapper({{ nameCamel }}));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get {{ nameLower }}: ${message}`);
res.status(400).json({
error: 'Failed to get {{ nameLower }}',
});
}
},
]);

View file

@ -0,0 +1,16 @@
Resources:
{{ nameCamel }}Table:
Type: AWS::DynamoDB::Table
DeletionPolicy: ${self:custom.deletionPolicy.${opt:stage}}
UpdateReplacePolicy: ${self:custom.updatePolicy.${opt:stage}}
Properties:
TableName: ${env:APP_NAME}-${opt:stage}-{{ nameKebab }}
AttributeDefinitions:
- AttributeName: {{ primaryKey }}
AttributeType: S
KeySchema:
- AttributeName: {{ primaryKey }}
KeyType: HASH
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true

View file

@ -0,0 +1,37 @@
Api{{ nameFirst }}:
handler: src/baseblocks/{{ nameKebab }}/{{ nameKebab }}-api.handler
events:
- http:
path: /{{ nameKebab }}/{any+}
method: ANY
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false
- http:
path: /{{ nameKebab }}
method: ANY
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false

View file

@ -0,0 +1,3 @@
[
{{ seedData }}
]

View file

@ -0,0 +1,14 @@
import { {{ nameFirst }} } from '@baseline/types/{{ nameKebab }}';
import { getDynamodbConnection } from '@baselinejs/dynamodb';
import { ServiceObject } from '../../util/service-object';
const dynamoDb = getDynamodbConnection({
region: `${process.env.API_REGION}`,
});
export const {{ nameCamel }}Service = new ServiceObject<{{ nameFirst }}>({
dynamoDb: dynamoDb,
objectName: '{{ nameFirst }}',
table: `${process.env.APP_NAME}-${process.env.NODE_ENV}-{{ nameKebab }}`,
primaryKey: '{{ primaryKey }}',
});

View file

@ -0,0 +1,7 @@
import { {{ nameFirst }} } from '@baseline/types/{{ nameKebab }}';
export const {{ nameCamel }}Mapper = (data: {{ nameFirst }}): {{ nameFirst }} => {
const {{ nameCamel }}: {{ nameFirst }} = {{{ mapperFields }}
};
return {{ nameCamel }};
};

View file

@ -0,0 +1,70 @@
import { {{ nameFirst }} } from '@baseline/types/{{ nameKebab }}';
import { RequestHandler } from './request-handler';
export const get{{ nameFirst }} = async (requestHandler: RequestHandler, {{ nameCamel }}Id: string): Promise<{{ nameFirst }}> => {
const response = await requestHandler.request<{{ nameFirst }}>({
method: 'GET',
url: `{{ nameKebab }}/${{{ nameCamel }}Id}`,
hasAuthentication: true,
});
if ('data' in response) {
return response.data;
}
throw response;
};
export const getAll{{ nameFirst }}s = async (requestHandler: RequestHandler): Promise<{{ nameFirst }}[]> => {
const response = await requestHandler.request<{{ nameFirst }}[]>({
method: 'GET',
url: `{{ nameKebab }}/list`,
hasAuthentication: true,
});
if ('data' in response) {
return response.data;
}
throw response;
};
export const delete{{ nameFirst }} = async (requestHandler: RequestHandler, {{ nameCamel }}Id: string): Promise<boolean> => {
const response = await requestHandler.request<boolean>({
method: 'DELETE',
url: `{{ nameKebab }}/${{{ nameCamel }}Id}`,
hasAuthentication: true,
});
if ('data' in response) {
return response.data;
}
throw response;
};
export const create{{ nameFirst }} = async (
requestHandler: RequestHandler,
{{ nameCamel }}: Partial<{{ nameFirst }}>,
): Promise<{{ nameFirst }}> => {
const response = await requestHandler.request<{{ nameFirst }}>({
method: 'POST',
url: `{{ nameKebab }}`,
hasAuthentication: true,
data: {{ nameCamel }},
});
if ('data' in response) {
return response.data;
}
throw response;
};
export const update{{ nameFirst }} = async (
requestHandler: RequestHandler,
{{ nameCamel }}: Partial<{{ nameFirst }}>,
): Promise<{{ nameFirst }}> => {
const response = await requestHandler.request<{{ nameFirst }}>({
method: 'PATCH',
url: `{{ nameKebab }}`,
hasAuthentication: true,
data: {{ nameCamel }},
});
if ('data' in response) {
return response.data;
}
throw response;
};

View file

@ -0,0 +1,3 @@
export interface {{ nameFirst }} {
{{ typeFields }}
}