Skip to content
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

added a better error message and a link to a full explanation #386

Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -2,3 +2,4 @@ dist
node_modules
routes.ts
.idea/
yarn-error.log
166 changes: 166 additions & 0 deletions ExternalInterfacesExplanation.MD
@@ -0,0 +1,166 @@
# Why am I getting this error?

`No matching model found for referenced type`

If you get that error, the most likely cause is that you are asking TSOA to create a swagger model from an interface that lives in an external dependency.

## Why is that a problem?

* Technical problem
* TSOA can't currently get interfaces from node_modules. For performance reasons, TSOA should not be crawling all of the files in node_modules.
* Architectural/Quality problem
* The consumers of the API you are writing do not want to have that API contract change on them. If your swagger/open-api documents/contracts were to be generated from external interfaces they would risk being changed any time that you update a library.

# Solution:

Create an interface that lives inside your own code base (i.e. it does live in `./node_modules`) but that has the same structure.

## Detailed Solution

Here's the code that's getting the `No matching model found` error:
```ts
import * as externalDependency from 'some-external-dependency';

@Route('Users')
export class UsersController {

/**
* Create a user
* @param request This is a user creation request description
*/
@Post()
public async Create(@Body() user: externalDependency.IUser): Promise<void> {
return externalDependency.doStuff(user);
}

}
```
And that external dependency has the following code:
```ts
// node_modules/someExternalDependency/index.d.ts

export interface IUser {
name: string;
}

export function doStuff(user: IUser) {
// ...
}
```

Here's how you solve it:
```ts
import * as externalDependency from 'some-external-dependency';

// a local copy:
interface IUserAbstraction {
name: string
}

@Route('Users')
export class UsersController {

/**
* Create a user
* @param request This is a user creation request description
*/
@Post()
public async Create(@Body() user: IUserAbstraction): Promise<void> {
return externalDependency.doStuff(user);
}

}
```

## But wait, but my code needs get data that the library can handle... will that work?
endor marked this conversation as resolved.
Show resolved Hide resolved

Yes, this will work just fine due to TypeScript's "structural subtyping." Here's an example.

Let's say you want to expose the `IHuman` interface because you need to call the `onlyAcceptsAHuman` function from a fictional NPM package called `HumanLogger`.

```ts
// node_modules/humanLogger/index.ts
export interface IHuman {
name: string;
}

export function onlyAcceptsAHuman(human: IHuman){
console.log(`I got a human with the name ${human.name}`
}
```
And here's your local code that has it's own interface that has a different name but the same structure. The fact that the following code compiles shows that the two interfaces can be substituted for each other due to structural subtyping:
endor marked this conversation as resolved.
Show resolved Hide resolved
```ts
import * as humanLogger from 'human-logger';

// your own code

interface IPerson {
name: string;
}

function makePerson(): IPerson {
return {
name: "Bob Smith"
};
}

humanLogger.onlyAcceptsAHuman( makePerson() ); // <-- yay! It compiles!
```

# Quality Benefits of this approach

Abstraction layers are a valuable concept in programming. Here's a classic example of why it will help you and your API consumers.

Let's say that `some-external-dependency` changes their method to take a different data structure.

If tsoa were to update your swagger documentation to thew new type, then your API consumers would get 400 BAD REQUEST errors. See here:
endor marked this conversation as resolved.
Show resolved Hide resolved

```ts
// node_modules/someExternalDependency/index.d.ts


export interface IUser {
// name: string; // we used to accept "name" but now we don't
firstName: string;
lastName: string;
}

export function doStuff(user: IUser) {
// ...
}
```
Now your swagger documentation has changed, but the API consumer hasn't been notified of the breaking contract. So they call the API:
```ts
const response = someHttpLib.post('/api/Users/', {
name: "Bob Smith"
}); // Throw 400 error: "firstName is a required parameter
endor marked this conversation as resolved.
Show resolved Hide resolved
```
We don't want our API consumers to have a new error pop up in their production applications, so instead we handle our internal change without having to bother our consumers:
```ts
import * as externalDependency from 'some-external-dependency';

// a local copy:
interface IUserAbstraction {
name: string
}

@Route('Users')
export class UsersController {

/**
* Create a user
* @param request This is a user creation request description
*/
@Post()
public async Create(@Body() user: IUserAbstraction): Promise<void> {
const namePortions = user.name.split(" ");
// translate the abstraction into the shape the library expects
const libUser: externalDependency.IUser = {
firstName: namePortions[0],
lastName: namePortions[1]
}
return externalDependency.doStuff(user);
}

}
```
2 changes: 2 additions & 0 deletions README.MD
Expand Up @@ -98,6 +98,8 @@ export class UsersController extends Controller {
}
}
```
Note: tsoa can not create swagger documents from interfaces that are defined in external dependencies. This is by design. Full explanation available in [ExternalInterfacesExplanation.MD](https://github.com/lukeautry/tsoa/blob/master/ExternalInterfacesExplanation.MD)

### Create Models
```typescript
// models/user.ts
Expand Down
2 changes: 1 addition & 1 deletion src/metadataGeneration/typeResolver.ts
Expand Up @@ -432,7 +432,7 @@ export class TypeResolver {
}) as UsableDeclaration[];

if (!modelTypes.length) {
throw new GenerateMetadataError(`No matching model found for referenced type ${typeName}.`);
throw new GenerateMetadataError(`No matching model found for referenced type ${typeName}. If ${typeName} comes from a dependency, please create an interface in your own code that has the same structure. Tsoa can not utilize interfaces from external dependencies. Read more at https://github.com/lukeautry/tsoa/blob/master/ExternalInterfacesExplanation.MD`);
endor marked this conversation as resolved.
Show resolved Hide resolved
}

if (modelTypes.length > 1) {
Expand Down
6 changes: 6 additions & 0 deletions tests/fixtures/controllers/getController.ts
Expand Up @@ -40,6 +40,12 @@ export class GetTestController extends Controller {
modelsArray: new Array<TestSubModel>(),
numberArray: [1, 2, 3],
numberValue: 1,
object: {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these necessary for the tests to compile?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, unfortunately they are necessary for the tests to compile. It happened because one of the previous PRs got merged without the tests running (woops!). But now that the previous PR added husky, these kind of things will be caught.

a: 'a',
},
objectArray: [{
a: 'a',
}],
optionalString: 'optional string',
strLiteralArr: ['Foo', 'Bar'],
strLiteralVal: 'Foo',
Expand Down
6 changes: 6 additions & 0 deletions tests/fixtures/inversify/managedService.ts
Expand Up @@ -15,6 +15,12 @@ export class ManagedService {
modelsArray: new Array<TestSubModel>(),
numberArray: [1, 2, 3],
numberValue: 1,
object: {
a: 'a',
},
objectArray: [{
a: 'a',
}],
optionalString: 'optional string',
strLiteralArr: ['Foo', 'Bar'],
strLiteralVal: 'Foo',
Expand Down
6 changes: 6 additions & 0 deletions tests/fixtures/services/modelService.ts
Expand Up @@ -13,6 +13,12 @@ export class ModelService {
modelsArray: new Array<TestSubModel>(),
numberArray: [1, 2, 3],
numberValue: 1,
object: {
a: 'a',
},
objectArray: [{
a: 'a',
}],
optionalString: 'optional string',
strLiteralArr: ['Foo', 'Bar'],
strLiteralVal: 'Foo',
Expand Down
6 changes: 6 additions & 0 deletions tests/integration/inversify-server.spec.ts
Expand Up @@ -40,6 +40,12 @@ describe('Inversify Express Server', () => {
},
numberArray: [1, 2, 3],
numberValue: 1,
object: {
a: 'a',
},
objectArray: [{
a: 'a',
}],
optionalString: 'optional string',
strLiteralArr: ['Foo', 'Bar'],
strLiteralVal: 'Foo',
Expand Down