Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion packages/rest-crud/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,32 @@ export class TryApplication extends BootMixin(

Create a new file for the configuration, e.g.
`src/model-endpoints/product.rest-config.ts` that defines the `model`,
`pattern`, `dataSource`, `basePath`, and `readonly` properties:
`pattern`, `dataSource`, `basePath`, `readonly` and optionaly `auth` with
authenticate method from @loopback/authentication and strategy properties:

```ts
import {ModelCrudRestApiConfig} from '@loopback/rest-crud';
import {Product} from '../models';
import {authenticate} from '@loopback/authentication';

module.exports = <ModelCrudRestApiConfig>{
model: Product,
pattern: 'CrudRest', // make sure to use this pattern
dataSource: 'db',
basePath: '/products',
readonly: false,
authenticate,
strategy: 'jwt', // if no strategy provided, the default would be jwt
auth: {
count: true,
get: true,
getById: true,
post: true,
patch: true,
patchById: true,
putById: true,
deleteById: true,
},
};
```

Expand Down
1 change: 1 addition & 0 deletions packages/rest-crud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"tslib": "^2.8.1"
},
"devDependencies": {
"@loopback/authentication": "^13.0.0",
"@loopback/build": "^13.0.0",
"@loopback/core": "^8.0.0",
"@loopback/repository": "^9.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved.
// Node module: @loopback/rest-crud
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {
defineCrudRepositoryClass,
Entity,
EntityCrudRepository,
juggler,
model,
property,
} from '@loopback/repository';
import {HttpErrors, RestApplication, Request} from '@loopback/rest';
import {
Client,
createRestAppClient,
givenHttpServerConfig,
} from '@loopback/testlab';
import {defineCrudRestController} from '../..';
import {
authenticate,
AuthenticationComponent,
AuthenticationStrategy,
registerAuthenticationStrategy,
} from '@loopback/authentication';
import {UserProfile, securityId} from '@loopback/security';

// Define a simple mock strategy
class MockAuthenticationStrategy implements AuthenticationStrategy {
name = 'custom-strategy';

async authenticate(request: Request): Promise<UserProfile | undefined> {
const authHeader =
request.headers.authorization || request.headers?.Authorization;
if (!authHeader) {
throw new HttpErrors.Unauthorized('Authorization header not found.');
}
return {
[securityId]: '1',
email: 'testing@loopback.io',
name: 'Test User',
};
}
}

// In this test scenario, we create a customer to test authentication
// for rest-crud (model-endpoints) based apis.
//
// Please don't add any other scenarios to this test file, create a new file
// for each scenario instead.

describe('CrudRestController for a simple Product model', () => {
@model()
class Customer extends Entity {
@property({id: true})
id: number;

@property({required: true})
name: string;

@property()
description?: string;

constructor(data: Partial<Customer>) {
super(data);
}
}

let app: RestApplication;
let repo: EntityCrudRepository<Customer, typeof Customer.prototype.id>;

let client: Client;

const PATCH_DATA = {description: 'patched'};
Object.freeze(PATCH_DATA);

before(setupTestScenario);
after(stopTheApp);

describe('authentication', () => {
it('fails to create a Customer without credentials', async () => {
await client.post('/customers').send({name: 'Jane Doe'}).expect(401);
});

it('fails to fetch all Customers without credentials', async () => {
await client.get('/customers').expect(401);
});

it('fails to fetch a single Customer without credentials', async () => {
await client.get('/customers/1').expect(401);
});

it('fails to fetch Customer count without credentials', async () => {
await client.get('/customers/count').expect(401);
});

it('fails to update Customers without credentials', async () => {
await client.patch('/customers').send(PATCH_DATA).expect(401);
});

it('fails to update a single Customer without credentials', async () => {
await client.patch('/customers/1').send(PATCH_DATA).expect(401);
});

it('fails to replace a single Customer without credentials', async () => {
await client.put('/customers/1').send(PATCH_DATA).expect(401);
});

it('fails to delete a single Customer without credentials', async () => {
await client.del('/customers/1').expect(401);
});

it('succeeds when credentaisl are provied', async () => {
await client
.get('/customers')
.set('Authorization', 'Bearer mock-token-value')
.expect(200);
});
});

async function setupTestScenario() {
const CustomerCrudRestController = defineCrudRestController<
Customer,
typeof Customer.prototype.id,
'id'
>(Customer, {
basePath: '/customers',
authenticate,
strategy: 'custom-strategy',
auth: {
post: true,
patch: true,
deleteById: true,
putById: true,
count: true,
get: true,
getById: true,
patchById: true,
},
});

const db = new juggler.DataSource({connector: 'memory'});

const CustomerRepository = defineCrudRepositoryClass(Customer);

repo = new CustomerRepository(db);

class CustomerController extends CustomerCrudRestController {
constructor() {
super(repo);
}
}

app = new RestApplication({rest: givenHttpServerConfig()});
app.component(AuthenticationComponent);
registerAuthenticationStrategy(app, MockAuthenticationStrategy);
app
.bind('authentication.strategy.custom-strategy')
.toClass(MockAuthenticationStrategy);
app.controller(CustomerController);

await app.start();
client = createRestAppClient(app);
}

async function stopTheApp() {
await app.stop();
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe('CrudRestController for a simple Product model', () => {

let app: RestApplication;
let repo: EntityCrudRepository<Product, typeof Product.prototype.id>;

let client: Client;

// sample data - call `seedData` to populate these items
Expand All @@ -67,7 +68,6 @@ describe('CrudRestController for a simple Product model', () => {
// FIXME: POST should return 201
// See https://github.com/loopbackio/loopback-next/issues/788
.expect(200);

const created = response.body;
expect(created).to.containEql({name: 'A pen'});
expect(created).to.have.property('id').of.type('number');
Expand Down
7 changes: 7 additions & 0 deletions packages/rest-crud/src/crud-rest.api-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export interface ModelCrudRestApiConfig extends ModelApiConfig {
* If true, the generated controller only has find and count APIs
*/
readonly?: boolean;
// strategy for auth
strategy?: string;
/**
* passing authenticate method to enable auth for rest-crud based APIs
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
authenticate?: ((strategy: string) => any) | undefined;
}

@injectable(asModelApiBuilder)
Expand Down
71 changes: 71 additions & 0 deletions packages/rest-crud/src/crud-rest.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,23 @@ export interface CrudRestControllerOptions {
* Whether to generate readonly APIs
*/
readonly?: boolean;
/**
* Whether to add authentication decorator or not
*/
auth?: {
count?: boolean;
get?: boolean;
getById?: boolean;
post?: boolean;
patch?: boolean;
patchById?: boolean;
putById?: boolean;
deleteById?: boolean;
};
// autheticate method from @loopback/authentication extension
// eslint-disable-next-line @typescript-eslint/no-explicit-any
authenticate?: ((strategy: string) => any) | undefined;
strategy?: string;
}

/**
Expand Down Expand Up @@ -157,6 +174,11 @@ export function defineCrudRestController<
public readonly repository: EntityCrudRepository<T, IdType, Relations>,
) {}

@authenticatedMethod(
options.auth?.get,
options.authenticate,
options.strategy,
)
@get('/', {
...response.array(200, `Array of ${modelName} instances`, modelCtor, {
includeRelations: true,
Expand All @@ -174,6 +196,11 @@ export function defineCrudRestController<
includeRelations: true,
}),
})
@authenticatedMethod(
options.auth?.getById,
options.authenticate,
options.strategy,
)
async findById(
@param(idPathParam) id: IdType,
@param.query.object(
Expand All @@ -188,6 +215,11 @@ export function defineCrudRestController<
@get('/count', {
...response(200, `${modelName} count`, {schema: CountSchema}),
})
@authenticatedMethod(
options.auth?.count,
options.authenticate,
options.strategy,
)
async count(
@param.where(modelCtor)
where?: Where<T>,
Expand All @@ -207,6 +239,11 @@ export function defineCrudRestController<
@post('/', {
...response.model(200, `${modelName} instance created`, modelCtor),
})
@authenticatedMethod(
options?.auth?.post,
options.authenticate,
options.strategy,
)
async create(
@body(modelCtor, {
title: `New${modelName}`,
Expand All @@ -226,6 +263,11 @@ export function defineCrudRestController<
schema: CountSchema,
}),
})
@authenticatedMethod(
options?.auth?.patch,
options.authenticate,
options.strategy,
)
async updateAll(
@body(modelCtor, {partial: true}) data: Partial<T>,
@param.where(modelCtor)
Expand All @@ -244,6 +286,11 @@ export function defineCrudRestController<
'204': {description: `${modelName} was updated`},
},
})
@authenticatedMethod(
options?.auth?.patchById,
options.authenticate,
options.strategy,
)
async updateById(
@param(idPathParam) id: IdType,
@body(modelCtor, {partial: true}) data: Partial<T>,
Expand All @@ -261,6 +308,11 @@ export function defineCrudRestController<
'204': {description: `${modelName} was updated`},
},
})
@authenticatedMethod(
options.auth?.putById,
options.authenticate,
options.strategy,
)
async replaceById(
@param(idPathParam) id: IdType,
@body(modelCtor) data: T,
Expand All @@ -273,6 +325,11 @@ export function defineCrudRestController<
'204': {description: `${modelName} was deleted`},
},
})
@authenticatedMethod(
options.auth?.deleteById,
options.authenticate,
options.strategy,
)
async deleteById(@param(idPathParam) id: IdType): Promise<void> {
await this.repository.deleteById(id);
}
Expand Down Expand Up @@ -363,3 +420,17 @@ namespace response {
});
}
}

// Helper function to conditionally add @authenticate decorator
function authenticatedMethod(
applyAuth: boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
authenticate: ((strategy: string) => any) | undefined,
strategy: string = 'jwt',
) {
if (authenticate && strategy && applyAuth) {
return authenticate(strategy);
} else {
return (target: Object, key: string, descriptor: PropertyDescriptor) => {};
}
}
3 changes: 3 additions & 0 deletions packages/rest-crud/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"src"
],
"references": [
{
"path": "../authentication/tsconfig.json"
},
{
"path": "../core/tsconfig.json"
},
Expand Down
Loading