-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: return 404 when a model was not found #1658
Conversation
@@ -211,7 +211,12 @@ export class DefaultCrudRepository<T extends Entity, ID> | |||
this.modelClass.findById(id, filter, options), | |||
); | |||
if (!model) { | |||
throw new Error(`no ${this.modelClass.name} found with id "${id}"`); | |||
const quotedId = JSON.stringify(id); | |||
const err: Error & {code?: string} = new Error( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe declare the type Error & {code?: string}
somewhere? We will be using it in other places too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point 👍
// TODO(bajtos) Make this mapping configurable at RestServer level, | ||
// allow apps and extensions to contribute additional mappings. | ||
const codeToStatusCodeMapping: {[key: string]: number} = { | ||
MODEL_NOT_FOUND: 404, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is our custom error code name, right? We'll have to document this and other possible error types. Eg:
- Database serve not reachable
- Authentication failure
- Authorization failure
- Runtime errors
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this is our custom error code. We are using the same code
property as Node.js core does, as a result we "inherit" all error codes from Node.js core too - see https://nodejs.org/api/errors.html#errors_node_js_error_codes and https://nodejs.org/api/errors.html#errors_common_system_errors
Database serve not reachable
I think this will typically manifests as ECONNREFUSED
, ECONNRESET
or ETIMEDOUT
.
Authentication failure
Authorization failure
Runtime errors
Agreed. See existing auth error codes in LB 3.x - e.g. common/models/user.js
@@ -22,7 +28,15 @@ export class RejectProvider implements Provider<Reject> { | |||
} | |||
|
|||
action({request, response}: HandlerContext, error: Error) { | |||
const err = <HttpError>error; | |||
const err = error as HttpError & {code?: string}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we declare the HttpError & {code?: string}
type somewhere?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am for this approach. It keeps the repository transport-agnostic and gets the job done without any changes in controller code.
It introduces a new Error type with code
property, which can be used by users to customize how they handle these types of errors, if they decide to. We will have to document the implemented code value (MODEL_NOT_FOUND
), and maybe also other possible values.
While I support the idea to have
|
I would hope / think that the intention would be to keep all the other methods consistent and this is just to demonstrate the idea.
I'm confused ... how does this approach not allow the consumers of findById API to handle the not found situation? Wouldn't a consumer of this API now be able to try / catch this error and know the issue vs. just getting a EDIT: Just saw the spring.io link. I guess an If the approach is to return a 404 I like what this PR implements and an alternative option can be for to introduce a |
I don't see a lot of JS developers use const customer = await findById('c01');
if (customer == null) {
// Not found
}
try {
const customer = await findById('c01');
} catch(e) {
if (e instanceOf NotFoundError) {
// Not reliable
}
if (e.code === 'NotFound') {
// ...
}
} |
The mongoose library returns As a result, when we used mongoose, we had to check the result of almost every After this experience, I feel that having a 404 be the default behaviour would be quite convenient, reducing boilerplate and uncertainty in the code. (Boilerplate: checking and throwing, uncertainty: forgetting to check and proceeding with a But I can see the benefits of Raymond's suggestion to optionally return However, a function that can sometimes return If so, would it be worth having a separate function |
Thank you @joeytwiddle for chiming in! It's always great to hear feedback from users 👍
We have recently landed #1621 which changed the default behavior of I think we need to rethink "not found" handling in all these three methods and implement a consistent solution.
I fully agree we should provide API that makes it easy for consumers to handle "not found" situation. At the same time, I'd like our API to make it easy for consumers to not have to deal with "not found" situation. In the past, I had success with providing two methods: The link to Spring framework is a great discussion starter. Notice that Spring's
An example to illustrate the point: const result = repo.findById(123);
// We are pretty sure the id exist
// Let the framework throw an error if not found
// (the check is implemented in "value" getter)
console.log(result.value);
// We don't know if the id exists
// Handle "not found" case ourselves
if (result.hasValue) {
console.log(result.value);
} else {
console.log('not found');
}
Consider the following controller method: async findTodo(id) {
return await this.repo.findById(id);
} The code looks fine, only an experienced LB4 user with a keen eye may notice that this method is going to return model data wrapped in
I agree that using try-catch to specifically handle "not found" error can be cumbersome. I disagree that returning
This can be handled by function overloads, we already do something similar for The idea is to tell TypeScript that the same function returns different types depending on the input arguments. A mock-up to illustrate my point, it may not work exactly this way. findById(id: ID): Promise<T>;
findById(id: ID, {optional: false}): Promise<T>;
findById(id: ID, {optional: true}): Promise<T | null>;
findById(id: ID, options: {optional: boolean}): Promise<T | null> {
const result: T | null = // get data via juggler
if (result) {
return result;
}
if (options && options.optional) {
return null;
}
throw /* not found error */
} |
In the the callback pattern Since we are moving on to Ideally, resolution should always return a resource of a single expected type. |
For references: TypeORM (https://github.com/typeorm/typeorm/blob/master/src/repository/Repository.ts#L268)
Sails.js (https://sailsjs.com/documentation/reference/waterline-orm/models/find-one)
BookShelf (http://bookshelfjs.org/#Model-instance-fetch)
|
Why not delegate this error type transformation to the controller and leave the current undefined if not found to the artifacts . @get('/daisies/{id}')
async findById(@param.path.number('id') id: number): Promise<Daisy> {
return await this.daisyRepository.findById(id);
} like such: @get('/daisies/{id}')
async findById(@param.path.number('id') id: number): Promise<Daisy> {
let result = await this.daisyRepository.find(filter);
if (result === undefined || result === null) {
throw new HttpErrors. NotFound(`id ${id} was not found`);
}
return result;
} At the end, 404 status is really something expected from a client application such as the following. The problem is that if we don't receive an http status error, then the logic should change in this snippet. this.dummyService.findDaisy(arg).toPromise().then(data => {
this.totalRecords = data;
// this.paginator.length = data;
}).catch(reason => { // do something }) ; |
That's how I would draw the line too. The repository API just returns |
While a null-returning API is practical for users that are expecting the model instance to not exist (e.g. a Controller method), it's very impractical for users that are certain the instance should exist (e.g. unit/integration tests). See my earlier comment: I fully agree we should provide API that makes it easy for consumers to handle "not found" situation. At the same time, I'd like our API to make it easy for consumers to not have to deal with "not found" situation. In the light of the discussion above, I am proposing the following plan going forward:
|
@marioestradarosa we considered that too. The problem with that we will have repetitive code in controllers - all @get('/daisies/{id}')
async findById(@param.path.number('id') id: number): Promise<Daisy> {
return await this.daisyRepository.findById(id);
} vs @get('/daisies/{id}')
async findById(@param.path.number('id') id: number): Promise<Daisy> {
return callThe404Helper(await this.daisyRepository.findById(id));
} That's not intuitive. Also, it requires a change in findById(id: ID, filter?: Filter, options?: Options): Promise<T>; should become findById(id: ID, filter?: Filter, options?: Options): Promise<T | null>; |
So, if we use @get('/daisies/{id}')
async findById(@param.path.number('id') id: number): Promise<Daisy> {
return await this.daisyRepository.findById(id);
} and if we use @get('/daisies/{id}')
async findById(@param.path.number('id') id: number): Promise<Daisy> {
let result = await this.daisyRepository.find(filter);
if (result === undefined || result === null) {
throw new HttpErrors. NotFound(`id ${id} was not found`);
}
return result;
} or @get('/daisies/{id}')
async findById(@param.path.number('id') id: number): Promise<Daisy> {
return callThe404Helper(await this.daisyRepository.findById(id));
} Is my understanding correct? |
@hacksparrow here is an example from the code I am working on: export class TodoController {
// ...
@get('/todos/{id}')
async findTodoById(
@param.path.number('id') id: number,
@param.query.boolean('items') items?: boolean,
): Promise<Todo> {
const maybeResult = await this.todoRepo.findById(id);
if (maybeResult) return maybeResult;
throw new EntityNotFoundError(Todo, id, {statusCode: 404});
}
} |
c355a2b
to
be2e68a
Compare
I reworked my pull request per my previous comment. I am keeping the original code in the git history for posterity, I'll rebase the first two commits away before landing this pull request. @raymondfeng @hacksparrow @marioestradarosa @joeytwiddle please review |
@hacksparrow You analysis is good and valid, but let me share with you that we are implementing some batch processes that will run periodically from unix cron. We are using the context and the Juggler from LB4 as they are independent packages, for this scenario developers are used to check the result for null or undefined and it is not related to controllers. The code in the controller as @bajtos is proposing below makes sense for keeping both worlds happy. It is repetitive ?, yes but it is generated and developers can changed it if they want to, however, I don't see any reason so far why they should remove this Wrapping it up inside the internal libraries doesn't allow this flexibility mentioned above. if (maybeResult) return maybeResult;
throw new EntityNotFoundError(Todo, id, {statusCode: 404});
} |
I agree that we should add those other methods too. Maybe
That is true. What other options are there? The discussion above mentions:
As as I see it, we have to make a trade-off here and accept that neither solution is going to be perfect.
As I wrote the list above, I actually like the last variant based on It is following the pattern we are already using in Context, where one can use It is also easy to apply to other methods like |
Or can we have a property in the app context, which configures how |
In a chat with @raymondfeng @hacksparrow and @virkt25, we proposed the following way forward: (1) (2) We will add a factory method allowing people to obtain a modified Repo instance that returns const repo = new ProductRepository(Product, db);
// findById throws by default
const repo = new ProductCrudRepo(repo);
const relaxedRepo: RelaxedCrudRepository<ProductRepository>
= repo.createRelaxedRepo(); // find a better name!
// relaxedRepo.findById(id) -> returns undefined not found We should also update (3) Add a type-guard for detecting whether an error is an (4) Keep controller methods simple, just |
6b739f4
to
7f35a3e
Compare
I have reworked the pull request to apply the direction outlined in the previous comment. To get these important changes landed sooner, I decided to leave the following feature out of scope of this pull request:
My intention is to start working on these two items as soon as this initial pull request is merged. As far as I am concerned, this pull request is ready for final review and landing. The changes are split into 5 commits, the review may be easier if you follow commit-by-commit. @raymondfeng @strongloop/sq-lb-apex @hacksparrow @marioestradarosa @joeytwiddle PTAL, LGTY now? |
The decrease is caused by my changes in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Although I haven't been involved in the discussions, I've read up on some of the conversation and reviewed your commits. Overall LGTM, but I've left some (minor) comments.
this.connector.find(this.model, {where: where}, options), | ||
); | ||
if (!entities.length) { | ||
throw new EntityNotFoundError(this.model.name, id); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we leverage this.model.modelName()
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am going to fix the type of the constructor argument model
and use this.model
(let EntityNotFoundError to obtain the model name).
} | ||
|
||
export function missingRequired(name: string): HttpErrors.HttpError { | ||
const msg = `Required parameter ${name} is missing!`; | ||
return new HttpErrors.BadRequest(msg); | ||
return Object.assign(new HttpErrors.BadRequest(msg), { | ||
code: 'MISSING_REQUIRED_PARAMETER', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we add these new error codes to the mapping you created for the 404
case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These errors are created inside REST layer, where we know about HTTP status codes and thus error.statusCode
is already set. No need to set statusCode
in the reject
action. In fact, the mapping preserves statusCode
if it's already set, and uses the lookup table only when no status code was provided by the error. Adding these new error codes to the lookup table would be a no-op that may confuse users IMO.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent PR!! Can't wait till this + the follow up PRs land and are released 😄
I do have a concern w.r.t this PR, IIUC we removed the await
/ async
from the controller so the error is passed to the sequence which will delegate to send
/reject
- Right?
If so, either in this PR / in a follow-up PR can we change all controller methods to return a promise instead of async
/ await
as I find one method not using it very weird and as a user I might think that it's an oversight and the async
/ await
keyword need to be added there. Apart from this, looks 💯
docs/site/Errors-handling.md
Outdated
title: Error Handling | ||
keywords: LoopBack 4.0 | ||
sidebar: lb4_sidebar | ||
permalink: /doc/en/lb4/Error-handling.html |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename this file for consistency please.
@@ -81,8 +82,8 @@ export class <%= className %>Controller { | |||
}, | |||
}, | |||
}) | |||
async findById(@param.path.<%= idType %>('id') id: <%= idType %>): Promise<<%= modelName %>> { | |||
return await this.<%= repositoryNameCamel %>.findById(id); | |||
findById(@param.path.<%= idType %>('id') id: <%= idType %>): Promise<<%= modelName %>> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm confused about the need for this change. All other controller methods are still using async/await. Why does this one need to be treated differently?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 to be consistent.
I am going to revert this change and keep the original async
method with return await
statement.
|
||
this.code = 'ENTITY_NOT_FOUND'; | ||
this.entityName = entityName; | ||
this.entityId = entityId; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we set this to quotedId
or not?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No. The purpose of quotedId
is to have a value that's easy to parse in a string, to make it easy for readers to distinguish between e.g. a number 1
and a string "1"
.
Error properties like entityId
and entityName
can be serialized in different ways in the HTTP response. When using JSON, the distinction between different types (undefined
, null
, a number, a string, an object/array) is preserved.
@virkt25 |
Fix tests using `expect().to.be.rejectedWith()` to await the result of the assertion before marking the test as passed.
Implement `modelName` getter that returns model's `definition.name` or model class name if no definition was provided.
@@ -9,6 +9,12 @@ import {HttpError} from 'http-errors'; | |||
import {RestBindings} from '../keys'; | |||
import {writeErrorToResponse, ErrorWriterOptions} from 'strong-error-handler'; | |||
|
|||
// TODO(bajtos) Make this mapping configurable at RestServer level, | |||
// allow apps and extensions to contribute additional mappings. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
allow apps and extensions to contribute additional mappings.
Just a note: allow add, disallow edit and delete.
Looks pretty good to land. |
7f35a3e
to
b6f02e1
Compare
Rework `findById` to return an instance of EntityNotFound error instead of creating a generic `Error` with extra properties.
Modify the `reject` action to convert ENTITY_NOT_FOUND code to response status code 404.
Add the following error codes: - VALIDATION_FAILED - INVALID_PARAMETER_VALUE - MISSING_REQUIRED_PARAMETER
b6f02e1
to
29c037c
Compare
Landed 🎉 |
This is an alternative solution to #1617 that fixes #1469.
modelName
is always available and match model definition.error.code
toENTITY_NOT_FOUND
string.code
isENTITY_NOT_FOUND
.reject
action to convertENTITY_NOT_FOUND
error code to response status code 404.VALIDATION_FAILED
,INVALID_PARAMETER_VALUE
,MISSING_REQUIRED_PARAMETER
Out of scope of this pull request:
Checklist
npm test
passes on your machinepackages/cli
were updatedexamples/*
were updated