Skip to content

Commit

Permalink
Merge pull request #386 from dgreene1/issue-384-betterDocumentationFo…
Browse files Browse the repository at this point in the history
…rInterfacesNotFound

added a better error message and a link to a full explanation
  • Loading branch information
dgreene1 committed Aug 7, 2019
2 parents 0f2cf08 + 8bf767b commit 41dcf54
Show file tree
Hide file tree
Showing 8 changed files with 196 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -2,3 +2,4 @@ dist
node_modules
routes.ts
.idea/
yarn-error.log
168 changes: 168 additions & 0 deletions ExternalInterfacesExplanation.MD
@@ -0,0 +1,168 @@
# 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);
}

}
```

## Does that solution have any negatives?

While it may appear that having a duplicate interface may be a performance problem, the types are stripped out when TypeScript compiles. So there won't be a performance problem.

And you also don't have to worry about the duplicate interface acting as a replacement for the external library's interface. 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 its 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:
```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 the new type, then your API consumers would get 400 BAD REQUEST errors. See here:
```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"
```
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`);
}

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: {
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

0 comments on commit 41dcf54

Please sign in to comment.