diff --git a/docs/site/Controller-generator.md b/docs/site/Controller-generator.md index ec0668d85a20..f2294e09de42 100644 --- a/docs/site/Controller-generator.md +++ b/docs/site/Controller-generator.md @@ -67,6 +67,10 @@ to select: - The model to use for the CRUD function definitions - The repository for this model that provides datasource connectivity +- The REST path naming convention + - Default: singular and plural forms of the model name in dash-delimited style + are used + - Custom: users are prompted for custom names of their REST paths {% include warning.html content= " If you do not have a model and repository to select, then you will receive an error! " lang=page.lang @@ -95,40 +99,40 @@ export class TodoController { public todoRepository: TodoRepository, ) {} - @post('/todo') + @post('/todos') async create(@requestBody() obj: Todo): Promise { return await this.todoRepository.create(obj); } - @get('/todo/count') + @get('/todos/count') async count(@param.query.string('where') where: Where): Promise { return await this.todoRepository.count(where); } - @get('/todo') + @get('/todos') async find(@param.query.string('filter') filter: Filter): Promise { return await this.todoRepository.find(filter); } - @patch('/todo') + @patch('/todos') async updateAll( @param.query.string('where') where: Where, - @reqeustBody() obj: Todo, + @requestBody() obj: Todo, ): Promise { return await this.todoRepository.updateAll(where, obj); } - @del('/todo') + @del('/todos') async deleteAll(@param.query.string('where') where: Where): Promise { return await this.todoRepository.deleteAll(where); } - @get('/todo/{id}') + @get('/todos/{id}') async findById(@param.path.number('id') id: number): Promise { return await this.todoRepository.findById(id); } - @patch('/todo/{id}') + @patch('/todos/{id}') async updateById( @param.path.number('id') id: number, @requestBody() obj: Todo, @@ -136,7 +140,7 @@ export class TodoController { return await this.todoRepository.updateById(id, obj); } - @del('/todo/{id}') + @del('/todos/{id}') async deleteById(@param.path.number('id') id: number): Promise { return await this.todoRepository.deleteById(id); } diff --git a/docs/site/Defining-the-API-using-code-first-approach.md b/docs/site/Defining-the-API-using-code-first-approach.md index 43c4c0d3f07d..23444fd2e4f2 100644 --- a/docs/site/Defining-the-API-using-code-first-approach.md +++ b/docs/site/Defining-the-API-using-code-first-approach.md @@ -137,12 +137,12 @@ import {post, get, param, requestBody} from '@loopback/openapi-v3'; export class TodoController { constructor() {} - @post('/todo') // same as @operation('post', '/todo'); + @post('/todos') // same as @operation('post', '/todos'); async createTodo(@requestBody() todo: Todo) { // data creating logic goes here } - @get('/todo/{id}') + @get('/todos/{id}') async findTodoById( @param.path.number('id') id: number, @param.query.boolean('items') items?: boolean, diff --git a/docs/site/todo-tutorial-controller.md b/docs/site/todo-tutorial-controller.md index d12b58a481d9..2cf7340b21cd 100644 --- a/docs/site/todo-tutorial-controller.md +++ b/docs/site/todo-tutorial-controller.md @@ -12,7 +12,7 @@ summary: LoopBack 4 Todo Application Tutorial - Add a Controller In LoopBack 4, controllers handle the request-response lifecycle for your API. Each function on a controller can be addressed individually to handle an -incoming request (like a POST request to `/todo`), perform business logic and +incoming request (like a POST request to `/todos`), perform business logic and then return a response. In this respect, controllers are the regions _in which most of your business @@ -74,7 +74,7 @@ export class TodoController { @repository(TodoRepository) protected todoRepo: TodoRepository, ) {} - @post('/todo') + @post('/todos') async createTodo(@requestBody() todo: Todo) { if (!todo.title) { throw new HttpErrors.BadRequest('title is required'); @@ -87,7 +87,7 @@ export class TodoController { In this example, we're using two new decorators to provide LoopBack with metadata about the route, verb and the format of the incoming request body: -- `@post('/todo')` creates metadata for `@loopback/rest` so that it can redirect +- `@post('/todos')` creates metadata for `@loopback/rest` so that it can redirect requests to this function when the path and verb match. - `@requestBody()` associates the OpenAPI schema for a Todo with the body of the request so that LoopBack can validate the format of an incoming request @@ -124,7 +124,7 @@ export class TodoController { @repository(TodoRepository) protected todoRepo: TodoRepository, ) {} - @post('/todo') + @post('/todos') async createTodo(@requestBody() todo: Todo) { if (!todo.title) { throw new HttpErrors.BadRequest('title is required'); @@ -132,17 +132,17 @@ export class TodoController { return await this.todoRepo.create(todo); } - @get('/todo/{id}') + @get('/todos/{id}') async findTodoById(@param.path.number('id') id: number): Promise { return await this.todoRepo.findById(id); } - @get('/todo') + @get('/todos') async findTodos(): Promise { return await this.todoRepo.find(); } - @put('/todo/{id}') + @put('/todos/{id}') async replaceTodo( @param.path.number('id') id: number, @requestBody() todo: Todo, @@ -153,7 +153,7 @@ export class TodoController { return await this.todoRepo.replaceById(id, todo); } - @patch('/todo/{id}') + @patch('/todos/{id}') async updateTodo( @param.path.number('id') id: number, @requestBody() todo: Todo, @@ -162,7 +162,7 @@ export class TodoController { return await this.todoRepo.updateById(id, todo); } - @del('/todo/{id}') + @del('/todos/{id}') async deleteTodo(@param.path.number('id') id: number): Promise { return await this.todoRepo.deleteById(id); } @@ -171,7 +171,7 @@ export class TodoController { Some additional things to note about this example: -- Routes like `@get('/todo/{id}')` can be paired with the `@param.path` +- Routes like `@get('/todos/{id}')` can be paired with the `@param.path` decorators to inject those values at request time into the handler function. - LoopBack's `@param` decorator also contains a namespace full of other "subdecorators" like `@param.path`, `@param.query`, and `@param.header` that diff --git a/docs/site/todo-tutorial-putting-it-together.md b/docs/site/todo-tutorial-putting-it-together.md index c6914436794a..9f3072a35bb7 100644 --- a/docs/site/todo-tutorial-putting-it-together.md +++ b/docs/site/todo-tutorial-putting-it-together.md @@ -112,10 +112,10 @@ your API and make requests! Here are some requests you can try: -- `POST /todo` with a body of `{ "title": "get the milk" }` -- `GET /todo/{id}` using the ID you received from your `POST`, and see if you +- `POST /todos` with a body of `{ "title": "get the milk" }` +- `GET /todos/{id}` using the ID you received from your `POST`, and see if you get your Todo object back. -- `PATCH /todo/{id}` with a body of `{ "desc": "need milk for cereal" }` +- `PATCH /todos/{id}` with a body of `{ "desc": "need milk for cereal" }` That's it! You've just created your first LoopBack 4 application! diff --git a/examples/todo/src/controllers/todo.controller.ts b/examples/todo/src/controllers/todo.controller.ts index 06f4b8496a5e..f5d3ef83b37c 100644 --- a/examples/todo/src/controllers/todo.controller.ts +++ b/examples/todo/src/controllers/todo.controller.ts @@ -20,7 +20,7 @@ import { export class TodoController { constructor(@repository(TodoRepository) protected todoRepo: TodoRepository) {} - @post('/todo') + @post('/todos') async createTodo(@requestBody() todo: Todo) { // TODO(bajtos) This should be handled by the framework // See https://github.com/strongloop/loopback-next/issues/118 @@ -30,7 +30,7 @@ export class TodoController { return await this.todoRepo.create(todo); } - @get('/todo/{id}') + @get('/todos/{id}') async findTodoById( @param.path.number('id') id: number, @param.query.boolean('items') items?: boolean, @@ -38,12 +38,12 @@ export class TodoController { return await this.todoRepo.findById(id); } - @get('/todo') + @get('/todos') async findTodos(): Promise { return await this.todoRepo.find(); } - @put('/todo/{id}') + @put('/todos/{id}') async replaceTodo( @param.path.number('id') id: number, @requestBody() todo: Todo, @@ -57,7 +57,7 @@ export class TodoController { return await this.todoRepo.replaceById(id, todo); } - @patch('/todo/{id}') + @patch('/todos/{id}') async updateTodo( @param.path.number('id') id: number, @requestBody() todo: Todo, @@ -71,7 +71,7 @@ export class TodoController { return await this.todoRepo.updateById(id, todo); } - @del('/todo/{id}') + @del('/todos/{id}') async deleteTodo(@param.path.number('id') id: number): Promise { return await this.todoRepo.deleteById(id); } diff --git a/examples/todo/test/acceptance/application.acceptance.ts b/examples/todo/test/acceptance/application.acceptance.ts index d463e313e6ee..0082b3682765 100644 --- a/examples/todo/test/acceptance/application.acceptance.ts +++ b/examples/todo/test/acceptance/application.acceptance.ts @@ -33,7 +33,7 @@ describe('Application', () => { it('creates a todo', async () => { const todo = givenTodo(); const response = await client - .post('/todo') + .post('/todos') .send(todo) .expect(200); expect(response.body).to.containEql(todo); @@ -44,7 +44,7 @@ describe('Application', () => { it('gets a todo by ID', async () => { const todo = await givenTodoInstance(); await client - .get(`/todo/${todo.id}`) + .get(`/todos/${todo.id}`) .send() .expect(200, todo); }); @@ -57,7 +57,7 @@ describe('Application', () => { isComplete: true, }); await client - .put(`/todo/${todo.id}`) + .put(`/todos/${todo.id}`) .send(updatedTodo) .expect(200); const result = await todoRepo.findById(todo.id); @@ -71,7 +71,7 @@ describe('Application', () => { isComplete: true, }); await client - .patch(`/todo/${todo.id}`) + .patch(`/todos/${todo.id}`) .send(updatedTodo) .expect(200); const result = await todoRepo.findById(todo.id); @@ -81,7 +81,7 @@ describe('Application', () => { it('deletes the todo', async () => { const todo = await givenTodoInstance(); await client - .del(`/todo/${todo.id}`) + .del(`/todos/${todo.id}`) .send() .expect(200); try { diff --git a/packages/cli/generators/controller/index.js b/packages/cli/generators/controller/index.js index f3a1157d2775..b51db95c7f88 100644 --- a/packages/cli/generators/controller/index.js +++ b/packages/cli/generators/controller/index.js @@ -144,6 +144,18 @@ module.exports = class ControllerGenerator extends ArtifactGenerator { when: this.artifactInfo.idType === undefined, default: 'number', }, + { + type: 'input', + name: 'httpPathName', + message: 'What is the base HTTP path name of the CRUD operations?', + when: this.artifactInfo.httpPathName === undefined, + default: answers => + utils.prependBackslash( + utils.pluralize(utils.urlSlug(answers.modelName)), + ), + validate: utils.validateUrlSlug, + filter: utils.prependBackslash, + }, ]).then(props => { debug(`props: ${inspect(props)}`); Object.assign(this.artifactInfo, props); @@ -159,9 +171,6 @@ module.exports = class ControllerGenerator extends ArtifactGenerator { this.artifactInfo.repositoryNameCamel = utils.camelCase( this.artifactInfo.repositoryName, ); - this.artifactInfo.modelNameCamel = utils.camelCase( - this.artifactInfo.modelName, - ); return props; }); }) diff --git a/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs b/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs index b6a6b9d0d0be..152191b3ab46 100644 --- a/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs +++ b/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs @@ -12,53 +12,56 @@ import {<%= modelName %>} from '../models'; import {<%= repositoryName %>} from '../repositories'; export class <%= name %>Controller { - constructor( @repository(<%= repositoryName %>) public <%= repositoryNameCamel %> : <%= repositoryName %>, ) {} - @post('/<%= modelNameCamel %>') + @post('<%= httpPathName %>') async create(@requestBody() obj: <%= modelName %>) : Promise<<%= modelName %>> { return await this.<%= repositoryNameCamel %>.create(obj); } - @get('/<%= modelNameCamel %>/count') - async count(@param.query.string('where') where: Where) : Promise { + @get('<%= httpPathName %>/count') + async count(@param.query.string('where') where: Where): Promise { return await this.<%= repositoryNameCamel %>.count(where); } - @get('/<%= modelNameCamel %>') + @get('<%= httpPathName %>') async find(@param.query.string('filter') filter: Filter) : Promise<<%= modelName %>[]> { - return await this.<%= repositoryNameCamel %>.find(filter); + return await this.<%= repositoryNameCamel %>.find(filter); } - @patch('/<%= modelNameCamel %>') - async updateAll(@param.query.string('where') where: Where, - @requestBody() obj: <%= modelName %>) : Promise { - return await this.<%= repositoryNameCamel %>.updateAll(where, obj); + @patch('<%= httpPathName %>') + async updateAll( + @param.query.string('where') where: Where, + @requestBody() obj: <%= modelName %> + ): Promise { + return await this.<%= repositoryNameCamel %>.updateAll(where, obj); } - @del('/<%= modelNameCamel %>') - async deleteAll(@param.query.string('where') where: Where) : Promise { + @del('<%= httpPathName %>') + async deleteAll(@param.query.string('where') where: Where): Promise { return await this.<%= repositoryNameCamel %>.deleteAll(where); } - @get('/<%= modelNameCamel %>/{id}') - async findById(@param.path.number('id') id: <%= idType %>) : Promise<<%= modelName %>> { + @get('<%= httpPathName %>/{id}') + async findById(@param.path.number('id') id: <%= idType %>): Promise<<%= modelName %>> { return await this.<%= repositoryNameCamel %>.findById(id); } - @patch('/<%= modelNameCamel %>/{id}') - async updateById(@param.path.number('id') id: <%= idType %>, @requestBody() - obj: <%= modelName %>) : Promise { + @patch('<%= httpPathName %>/{id}') + async updateById( + @param.path.number('id') id: <%= idType %>, + @requestBody() obj: <%= modelName %> + ): Promise { return await this.<%= repositoryNameCamel %>.updateById(id, obj); } - @del('/<%= modelNameCamel %>/{id}') - async deleteById(@param.path.number('id') id: <%= idType %>) : Promise { + @del('<%= httpPathName %>/{id}') + async deleteById(@param.path.number('id') id: <%= idType %>): Promise { return await this.<%= repositoryNameCamel %>.deleteById(id); } } diff --git a/packages/cli/lib/utils.js b/packages/cli/lib/utils.js index eba6461c853f..5a7149f55611 100644 --- a/packages/cli/lib/utils.js +++ b/packages/cli/lib/utils.js @@ -16,6 +16,8 @@ const _ = require('lodash'); const pascalCase = require('change-case').pascalCase; const promisify = require('util').promisify; const camelCase = require('change-case').camelCase; +const pluralize = require('pluralize'); +const urlSlug = require('url-slug'); const validate = require('validate-npm-package-name'); const Conflicter = require('yeoman-generator/lib/util/conflicter'); @@ -112,6 +114,8 @@ exports.kebabCase = _.kebabCase; exports.pascalCase = pascalCase; exports.camelCase = camelCase; +exports.pluralize = pluralize; +exports.urlSlug = urlSlug; exports.validate = function(name) { const isValid = validate(name).validForNewPackages; @@ -119,6 +123,33 @@ exports.validate = function(name) { return isValid; }; +/** + * Adds a backslash to the start of the word if not already present + * @param {string} httpPath + */ +exports.prependBackslash = httpPath => httpPath.replace(/^\/?/, '/'); + +/** + * Validates whether a given string is a valid url slug or not. + * Allows slugs with backslash in front of them to be validated as well + * @param {string} name Slug to validate + */ +exports.validateUrlSlug = function(name) { + const backslashIfNeeded = name.charAt(0) === '/' ? '/' : ''; + if (backslashIfNeeded === '/') { + name = name.substr(1); + } + const separators = ['-', '.', '_', '~', '']; + const possibleSlugs = separators.map(separator => + urlSlug(name, separator, false), + ); + if (!possibleSlugs.includes(name)) + return `Invalid url slug. Suggested slug: ${backslashIfNeeded}${ + possibleSlugs[0] + }`; + return true; +}; + /** * Extends conflicter so that it keeps track of conflict status */ diff --git a/packages/cli/package.json b/packages/cli/package.json index 173237fa084c..c4c5ac460aad 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,9 +46,11 @@ "lodash": "^4.17.5", "minimist": "^1.2.0", "pacote": "^8.1.1", + "pluralize": "^7.0.0", "regenerate": "^1.3.3", "semver": "^5.5.0", "unicode-10.0.0": "^0.7.4", + "url-slug": "^2.0.0", "validate-npm-package-name": "^3.0.0", "yeoman-generator": "^2.0.3" }, diff --git a/packages/cli/test/integration/generators/controller.integration.js b/packages/cli/test/integration/generators/controller.integration.js index 95cf1db0fe2d..9d67f438465c 100644 --- a/packages/cli/test/integration/generators/controller.integration.js +++ b/packages/cli/test/integration/generators/controller.integration.js @@ -12,6 +12,7 @@ const assert = require('yeoman-assert'); const helpers = require('yeoman-test'); const fs = require('fs'); const util = require('util'); +const utils = require('../../../lib/utils'); const testUtils = require('../../test-utils'); const ControllerGenerator = require('../../../generators/controller'); @@ -24,11 +25,11 @@ const templateName = testUtils.givenAControllerPath( 'controller-template.ts', ); const withInputProps = { - name: 'fooBar', + name: 'productReview', }; const withInputName = testUtils.givenAControllerPath( null, - 'foo-bar.controller.ts', + 'product-review.controller.ts', ); describe('controller-generator extending BaseGenerator', baseTests); @@ -46,6 +47,7 @@ describe('lb4 controller', () => { assert.noFile(withInputName); }); }); + it('does not run without the loopback keyword', () => { return testUtils .runGeneratorWith( @@ -69,10 +71,12 @@ describe('lb4 controller', () => { }) .toPromise(); }); + it('writes correct file name', () => { assert.file(tmpDir + withInputName); assert.noFile(tmpDir + templateName); }); + it('scaffolds correct files', () => { checkBasicContents(tmpDir); }); @@ -85,12 +89,14 @@ describe('lb4 controller', () => { tmpDir = dir; testUtils.givenAnApplicationDir(dir); }) - .withArguments('fooBar'); + .withArguments('productReview'); }); + it('writes correct file name', () => { assert.file(tmpDir + withInputName); assert.noFile(tmpDir + templateName); }); + it('scaffolds correct files', () => { checkBasicContents(tmpDir); }); @@ -99,9 +105,10 @@ describe('lb4 controller', () => { describe('REST CRUD', () => { const baseInput = { - name: 'fooBar', + name: 'productReview', controllerType: ControllerGenerator.REST, }; + it('creates REST CRUD template with valid input', () => { let tmpDir; return testUtils @@ -109,7 +116,7 @@ describe('lb4 controller', () => { generator, Object.assign( { - modelName: 'Foo', + modelName: 'ProductReview', repositoryName: 'BarRepository', id: 'number', }, @@ -117,15 +124,7 @@ describe('lb4 controller', () => { ), dir => { tmpDir = dir; - testUtils.givenAnApplicationDir(tmpDir); - fs.writeFileSync( - testUtils.givenAModelPath(tmpDir, 'foo.model.ts'), - '--DUMMY VALUE--', - ); - fs.writeFileSync( - testUtils.givenARepositoryPath(tmpDir, 'bar.repository.ts'), - '--DUMMY VALUE--', - ); + givenModelAndRepository(tmpDir); }, ) .then(() => { @@ -133,6 +132,55 @@ describe('lb4 controller', () => { }); }); + describe('HTTP REST path', () => { + it('defaults correctly', () => { + let tmpDir; + return testUtils + .runGeneratorWith( + generator, + Object.assign( + { + modelName: 'ProductReview', + repositoryName: 'BarRepository', + id: 'number', + }, + baseInput, + ), + dir => { + tmpDir = dir; + givenModelAndRepository(tmpDir); + }, + ) + .then(() => { + checkRestPaths(tmpDir, '/product-reviews'); + }); + }); + + it('honors custom http PATHs', () => { + let tmpDir; + return testUtils + .runGeneratorWith( + generator, + Object.assign( + { + modelName: 'ProductReview', + repositoryName: 'BarRepository', + id: 'number', + httpPathName: '/customer-orders', + }, + baseInput, + ), + dir => { + tmpDir = dir; + givenModelAndRepository(tmpDir); + }, + ) + .then(() => { + checkRestPaths(tmpDir, '/customer-orders'); + }); + }); + }); + it('fails when no model is given', () => { return noModelGiven(baseInput).catch(err => { expect(err.message).to.match(/No models found in /); @@ -162,6 +210,22 @@ describe('lb4 controller', () => { }); }); + /** + * Helper function for setting model and repository input + * @param {string} tmpDir The temporary directory to set up model & repository + */ + function givenModelAndRepository(tmpDir) { + testUtils.givenAnApplicationDir(tmpDir); + fs.writeFileSync( + testUtils.givenAModelPath(tmpDir, 'product-review.model.ts'), + '--DUMMY VALUE--', + ); + fs.writeFileSync( + testUtils.givenARepositoryPath(tmpDir, 'bar.repository.ts'), + '--DUMMY VALUE--', + ); + } + /** * Helper function for testing behaviour without model input. * @param {object} baseInput The base input for the controller type. @@ -196,7 +260,7 @@ describe('lb4 controller', () => { generator, Object.assign( { - modelName: 'Foo', + modelName: 'ProductReview', id: 'number', }, baseInput, @@ -204,7 +268,7 @@ describe('lb4 controller', () => { dir => { testUtils.givenAnApplicationDir(dir); fs.writeFileSync( - testUtils.givenAModelPath(dir, 'foo.model.ts'), + testUtils.givenAModelPath(dir, 'product-review.model.ts'), '--DUMMY VALUE--', ); }, @@ -221,7 +285,7 @@ describe('lb4 controller', () => { generator, Object.assign( { - modelName: 'Foo', + modelName: 'ProductReview', repositoryName: 'BarRepository', id: 'number', }, @@ -247,7 +311,7 @@ describe('lb4 controller', () => { generator, Object.assign( { - modelName: 'Foo', + modelName: 'ProductReview', repositoryName: 'BarRepository', id: 'number', }, @@ -256,7 +320,7 @@ describe('lb4 controller', () => { dir => { testUtils.givenAnApplicationDir(dir, {omitRepositoryDir: true}); fs.writeFileSync( - testUtils.givenAModelPath(dir, 'foo.model.ts'), + testUtils.givenAModelPath(dir, 'product-review.model.ts'), '--DUMMY VALUE--', ); }, @@ -264,7 +328,7 @@ describe('lb4 controller', () => { } function checkBasicContents(tmpDir) { - assert.fileContent(tmpDir + withInputName, /class FooBarController/); + assert.fileContent(tmpDir + withInputName, /class ProductReviewController/); assert.fileContent(tmpDir + withInputName, /constructor\(\) {}/); } @@ -278,7 +342,7 @@ describe('lb4 controller', () => { * @param {String} tmpDir */ function checkRestCrudContents(tmpDir) { - assert.fileContent(tmpDir + withInputName, /class FooBarController/); + assert.fileContent(tmpDir + withInputName, /class ProductReviewController/); // Repository and injection assert.fileContent(tmpDir + withInputName, /\@repository\(BarRepository\)/); @@ -290,37 +354,72 @@ describe('lb4 controller', () => { // Assert that the decorators are present in the correct groupings! assert.fileContent( tmpDir + withInputName, - /\@post\('\/foo'\)\s{1,}async create\(\@requestBody\(\)/, + /\@post\('\/product-reviews'\)\s{1,}async create\(\@requestBody\(\)/, ); assert.fileContent( tmpDir + withInputName, - /\@get\('\/foo\/count'\)\s{1,}async count\(\@param.query.string\('where'\)/, + /\@get\('\/product-reviews\/count'\)\s{1,}async count\(\@param.query.string\('where'\)/, ); assert.fileContent( tmpDir + withInputName, - /\@get\('\/foo'\)\s{1,}async find\(\@param.query.string\('filter'\)/, + /\@get\('\/product-reviews'\)\s{1,}async find\(\@param.query.string\('filter'\)/, + ); + assert.fileContent( + tmpDir + withInputName, + /\@patch\('\/product-reviews'\)\s{1,}async updateAll\(\s{1,}\@param.query.string\('where'\) where: Where,\s{1,}\@requestBody\(\)/, + ); + assert.fileContent( + tmpDir + withInputName, + /\@del\('\/product-reviews'\)\s{1,}async deleteAll\(\@param.query.string\('where'\)/, + ); + assert.fileContent( + tmpDir + withInputName, + /\@get\('\/product-reviews\/{id}'\)\s{1,}async findById\(\@param.path.number\('id'\)/, + ); + assert.fileContent( + tmpDir + withInputName, + /\@patch\('\/product-reviews\/{id}'\)\s{1,}async updateById\(\s{1,}\@param.path.number\('id'\) id: number,\s{1,}\@requestBody\(\)/, + ); + assert.fileContent( + tmpDir + withInputName, + /\@del\('\/product-reviews\/{id}'\)\s{1,}async deleteById\(\@param.path.number\('id'\) id: number\)/, + ); + } + + function checkRestPaths(tmpDir, restUrl) { + assert.fileContent( + tmpDir + withInputName, + new RegExp(/@post\('/.source + restUrl + /'\)/.source), + ); + assert.fileContent( + tmpDir + withInputName, + new RegExp(/@get\('/.source + restUrl + /\/count'\)/.source), + ); + assert.fileContent( + tmpDir + withInputName, + new RegExp(/@get\('/.source + restUrl + /'\)/.source), ); assert.fileContent( tmpDir + withInputName, - /\@patch\('\/foo'\)\s{1,}async updateAll\(\@param.query.string\('where'\) where: Where,\s{1,}\@requestBody\(\)/, + new RegExp(/@patch\('/.source + restUrl + /'\)/.source), ); assert.fileContent( tmpDir + withInputName, - /\@del\('\/foo'\)\s{1,}async deleteAll\(\@param.query.string\('where'\)/, + new RegExp(/@del\('/.source + restUrl + /'\)/.source), ); assert.fileContent( tmpDir + withInputName, - /\@get\('\/foo\/{id}'\)\s{1,}async findById\(\@param.path.number\('id'\)/, + new RegExp(/@get\('/.source + restUrl + /\/{id}'\)/.source), ); assert.fileContent( tmpDir + withInputName, - /\@patch\('\/foo\/{id}'\)\s{1,}async updateById\(\@param.path.number\('id'\) id: number, \@requestBody\(\)/, + new RegExp(/@patch\('/.source + restUrl + /\/{id}'\)/.source), ); assert.fileContent( tmpDir + withInputName, - /\@del\('\/foo\/{id}'\)\s{1,}async deleteById\(\@param.path.number\('id'\) id: number\)/, + new RegExp(/@del\('/.source + restUrl + /\/{id}'\)/.source), ); } }); diff --git a/packages/cli/test/unit/utils.unit.js b/packages/cli/test/unit/utils.unit.js index 5475db708f03..7fd5ead4fb2e 100644 --- a/packages/cli/test/unit/utils.unit.js +++ b/packages/cli/test/unit/utils.unit.js @@ -11,54 +11,65 @@ describe('Utils', () => { describe('validateClassName', () => { describe('validRegex', () => { const regex = utils.validRegex; + it('should return a RegExp', () => { expect(regex).to.be.an.instanceOf(RegExp); }); + it('should match "className"', () => { expect(regex.test('className')).to.equal(true); }); }); + describe('should not validate', () => { testValidateName( 'if the class name is empty', '', /name cannot be empty/, ); + testValidateName( 'if the class name is null', null, /name cannot be empty/, ); + testValidateName( 'if the first character is a digit', '2Controller', /name cannot start with a number/, ); + testValidateName( 'if the class name contains a period', 'Cool.App', /name cannot contain \./, ); + testValidateName( 'if the class name contains a space', 'foo bar', /name cannot contain space/, ); + testValidateName( 'if the class name contains a hyphen', 'foo-bar', /name cannot contain hyphen/, ); + testValidateName( 'if the class name contains special characters', 'Foo%bar', /name cannot contain special character/, ); + testValidateName( 'if the class name contains other invalid symbols', 'foo♡bar', /name is invalid/, ); + function testValidateName(testName, input, expected) { it(testName, () => { expect(utils.validateClassName(input)).to.match(expected); @@ -80,6 +91,7 @@ describe('Utils', () => { 'if the first character has an accented character', 'Óoobar', ); + function testCorrectName(testName, input) { it(testName, () => { expect(utils.validateClassName(input)).to.equal(true); @@ -93,10 +105,12 @@ describe('Utils', () => { testExpectError('if input is null', null, /bad input/); testExpectError('if input is not a string', 42, /bad input/); }); + describe('should have no effect', () => { testExpectNoChange('if first letter is capitalized', 'FooBar'); testExpectNoChange('if first letter is not convertible', '$fooBar'); }); + describe('should capitalize first letter', () => { testExpectUpperCase('if first letter is lower case', 'fooBar', 'FooBar'); testExpectUpperCase( @@ -105,22 +119,83 @@ describe('Utils', () => { 'ÓooBar', ); }); + function testExpectError(testName, input, expected) { it(testName, () => { expect(utils.toClassName(input)).to.match(expected); }); } + function testExpectNoChange(testName, input) { it(testName, () => { expect(utils.toClassName(input)).to.equal(input); }); } + function testExpectUpperCase(testName, input, expected) { it(testName, () => { expect(utils.toClassName(input)).to.equal(expected); }); } }); + + describe('validateUrlSlug', () => { + it('returns true for slug in plural form', () => { + expect(utils.validateUrlSlug('foos')).to.be.true(); + }); + + it('returns true for slug in camelCase', () => { + expect(utils.validateUrlSlug('fooBar')).to.be.true(); + }); + + it('returns true for slug with lower and upper case letters', () => { + expect(utils.validateUrlSlug('fOoBaR')).to.be.true(); + }); + + it('returns true for slugs separated by underscores', () => { + expect(utils.validateUrlSlug('foo_Bar')).to.be.true(); + }); + + it('returns true for slug with backslash in front', () => { + expect(utils.validateUrlSlug('/foo-bar')).to.be.true(); + }); + + it('does not validate an invalid url slug', () => { + expect(utils.validateUrlSlug('foo#bars')).to.match( + /Suggested slug: foo-bars/, + ); + expect(utils.validateUrlSlug('foo bar')).to.match( + /Suggested slug: foo-bar/, + ); + expect(utils.validateUrlSlug('/foo&bar')).to.match( + /Suggested slug: \/foo-bar/, + ); + expect(utils.validateUrlSlug('//foo-bar')).to.match( + /Suggested slug: \/foo-bar/, + ); + expect(utils.validateUrlSlug('/foo-bar/')).to.match( + /Suggested slug: \/foo-bar/, + ); + expect(utils.validateUrlSlug('foo-bar/')).to.match( + /Suggested slug: foo-bar/, + ); + }); + }); + + describe('prependBackslash', () => { + it('appends backslash if given word does not have any', () => { + expect(utils.prependBackslash('product-review')).to.eql( + '/product-review', + ); + }); + + it('does nothing if given word already has a backslash', () => { + expect(utils.prependBackslash('/product-review')).to.eql( + '/product-review', + ); + }); + }); + describe('findArtifactPaths', () => { it('returns all matching paths of type', () => { const expected = [