fastify / fastify-swagger

Swagger documentation generator for Fastify
MIT License
889 stars 199 forks source link

Error: .swagger() must be called after .ready() #758

Closed Xhale1 closed 4 months ago

Xhale1 commented 10 months ago

Prerequisites

Fastify version

4.23.2

Plugin version

8.10.1

Node.js version

18.17.1

Operating system

macOS

Operating system version (i.e. 20.04, 11.3, 10)

13.5.2

Description

Upon updating from 8.10.0 to 8.10.1, I'm now receiving this error when trying to start my Fastify server:

Error: .swagger() must be called after .ready()

Steps to Reproduce

Register the fastify swagger plugin in it's own encapsulation context (not sure if this is the exact right term, basically just it's own function with other routes), and use the following code to register it:

// this function is called from server.ts with `fastify.register(routes)`
export default async function routes(fastify: FastifyInstance) {
  fastify.register(fastifySwagger, { 
    // ... options
 });

  // ... other code redacted
  // primarily just registering routes

  fastify.ready((err) => {
    if (err) throw err;
    fastify.swagger();
  });
}

Expected Behavior

Fastify swagger is registered after fastify is ready.

mcollina commented 10 months ago

The problem with .swagger() before .ready() is that it generates incorrect schema, and there is no way to make it correct before the ready flow completed.

Whats your actual bug? Could you add a reproduce?

osbornm commented 10 months ago

@mcollina I'm having the same issue with 8.10.1 the ready function takes a callback that will be called after all plugins are loaded. 8.10.0 handled this registration method correctly, it's only after the patch update that this error occurs.

see: https://fastify.dev/docs/latest/Reference/Server/#ready

osbornm commented 10 months ago

downgrading to 8.10.0 resolves the issue for us

mcollina commented 10 months ago

Can you provide steps to reproduce? We often need a reproducible example, e.g. some code that allows someone else to recreate your problem by just copying and pasting it. If it involves more than a couple of different file, create a new repository on GitHub and add a link to that.

osbornm commented 10 months ago

@mcollina @Xhale1 did post the steps to reproduce when they opened the issue. it's just changing the call to ready and swagger to

fastify.ready((err) => {
    if (err) throw err;
    fastify.swagger();
  });

on any app you have

mcollina commented 10 months ago

@osbornm the following works correctly:

'use strict'

const fastify = require('fastify')()

fastify.register(require('../index'), {
  openapi: {
    info: {
      title: 'Test swagger',
      description: 'testing the fastify swagger api',
      version: '0.1.0'
    },
    servers: [{
      url: 'http://localhost'
    }],
    components: {
      securitySchemes: {
        apiKey: {
          type: 'apiKey',
          name: 'apiKey',
          in: 'header'
        }
      }
    }
  },
  hideUntagged: true,
  exposeRoute: true
})

fastify.register(async function (fastify) {
  fastify.put('/some-route/:id', {
    schema: {
      description: 'post some data',
      tags: ['user', 'code'],
      summary: 'qwerty',
      security: [{ apiKey: [] }],
      params: {
        type: 'object',
        properties: {
          id: {
            type: 'string',
            description: 'user id'
          }
        }
      },
      body: {
        type: 'object',
        properties: {
          hello: { type: 'string' },
          obj: {
            type: 'object',
            properties: {
              some: { type: 'string' }
            }
          }
        }
      },
      response: {
        201: {
          description: 'Succesful response',
          type: 'object',
          properties: {
            hello: { type: 'string' }
          }
        },
        default: {
          description: 'Default response',
          type: 'object',
          properties: {
            foo: { type: 'string' }
          }
        }
      }
    }
  }, (req, reply) => { reply.send({ hello: `Hello ${req.body.hello}` }) })

  fastify.post('/some-route/:id', {
    schema: {
      description: 'post some data',
      summary: 'qwerty',
      security: [{ apiKey: [] }],
      params: {
        type: 'object',
        properties: {
          id: {
            type: 'string',
            description: 'user id'
          }
        }
      },
      body: {
        type: 'object',
        properties: {
          hello: { type: 'string' },
          obj: {
            type: 'object',
            properties: {
              some: { type: 'string' }
            }
          }
        }
      },
      response: {
        201: {
          description: 'Succesful response',
          type: 'object',
          properties: {
            hello: { type: 'string' }
          }
        }
      }
    }
  }, (req, reply) => { reply.send({ hello: `Hello ${req.body.hello}` }) })
})

fastify.ready(err => {
  if (err) throw err
  console.log(fastify.swagger())
})
smorcuend commented 10 months ago

Error persists when fastify-swagger is loaded by @fastify/autoload:

export default fp<SwaggerOptions>(async fastify => {
    fastify.register(fastifySwagger, {
        openapi: {...}
    })

    await fastify.register(fastifySwaggerUi, {
        routePrefix: 'docs',
        uiConfig: {
            docExpansion: 'full',
            deepLinking: false
        },
        uiHooks: {
            onRequest: function (request, reply, next) {
                next()
            },
            preHandler: function (request, reply, next) {
                next()
            }
        },
        staticCSP: true,
        transformStaticCSP: header => header,
        transformSpecification: swaggerObject => {
            return swaggerObject
        },
        transformSpecificationClone: true
    })
})

Stack trace:

[App] [14:02:08.165] ERROR (298847): .swagger() must be called after .ready() [App] reqId: "req-7" [App] req: { [App] "method": "GET", [App] "url": "/docs/json", [App] "hostname": "localhost:9000", [App] "remoteAddress": "::1", [App] "remotePort": 49798 [App] } [App] res: { [App] "statusCode": 500 [App] } [App] err: { [App] "type": "Error", [App] "message": ".swagger() must be called after .ready()", [App] "stack": [App] Error: .swagger() must be called after .ready()

mcollina commented 10 months ago

Can you provide steps to reproduce? We often need a reproducible example, e.g. some code that allows someone else to recreate your problem by just copying and pasting it. If it involves more than a couple of different file, create a new repository on GitHub and add a link to that.

Note that if it throws that error, it means that your swagger document is incomplete or invalid. There is already a bug in your code, you just don't know about it.

Xhale1 commented 9 months ago

@mcollina I believe in order to reproduce the issue you would need to register the swagger plugin from within an encapsulated context (perhaps using the wrong terminology here). Your example would become:

'use strict'

const fastify = require('fastify')()

fastify.register(async function (fastify) {
  fastify.register(require('../index'), {
    openapi: {
      info: {
        title: 'Test swagger',
        description: 'testing the fastify swagger api',
        version: '0.1.0'
      },
      servers: [{
        url: 'http://localhost'
      }],
      components: {
        securitySchemes: {
          apiKey: {
            type: 'apiKey',
            name: 'apiKey',
            in: 'header'
          }
        }
      }
    },
    hideUntagged: true,
    exposeRoute: true
  })

  fastify.put('/some-route/:id', {
    schema: {
      description: 'post some data',
      tags: ['user', 'code'],
      summary: 'qwerty',
      security: [{ apiKey: [] }],
      params: {
        type: 'object',
        properties: {
          id: {
            type: 'string',
            description: 'user id'
          }
        }
      },
      body: {
        type: 'object',
        properties: {
          hello: { type: 'string' },
          obj: {
            type: 'object',
            properties: {
              some: { type: 'string' }
            }
          }
        }
      },
      response: {
        201: {
          description: 'Succesful response',
          type: 'object',
          properties: {
            hello: { type: 'string' }
          }
        },
        default: {
          description: 'Default response',
          type: 'object',
          properties: {
            foo: { type: 'string' }
          }
        }
      }
    }
  }, (req, reply) => { reply.send({ hello: `Hello ${req.body.hello}` }) })

  fastify.post('/some-route/:id', {
    schema: {
      description: 'post some data',
      summary: 'qwerty',
      security: [{ apiKey: [] }],
      params: {
        type: 'object',
        properties: {
          id: {
            type: 'string',
            description: 'user id'
          }
        }
      },
      body: {
        type: 'object',
        properties: {
          hello: { type: 'string' },
          obj: {
            type: 'object',
            properties: {
              some: { type: 'string' }
            }
          }
        }
      },
      response: {
        201: {
          description: 'Succesful response',
          type: 'object',
          properties: {
            hello: { type: 'string' }
          }
        }
      }
    }
  }, (req, reply) => { reply.send({ hello: `Hello ${req.body.hello}` }) })

  fastify.ready(err => {
    if (err) throw err
    console.log(fastify.swagger())
  })
})
mcollina commented 9 months ago

Unfortunately your code is hitting the race condition that would generate invalid schemas.

Here is an updated version fixed:

'use strict'

const fastify = require('fastify')()

fastify.register(async function (fastify) {
  // await is necessary here to make sure its onReady hook is registered
  // before the one of this module
  await fastify.register(require('./index'), {
    openapi: {
      info: {
        title: 'Test swagger',
        description: 'testing the fastify swagger api',
        version: '0.1.0'
      },
      servers: [{
        url: 'http://localhost'
      }],
      components: {
        securitySchemes: {
          apiKey: {
            type: 'apiKey',
            name: 'apiKey',
            in: 'header'
          }
        }
      }
    },
    hideUntagged: true,
    exposeRoute: true
  })

  fastify.put('/some-route/:id', {
    schema: {
      description: 'post some data',
      tags: ['user', 'code'],
      summary: 'qwerty',
      security: [{ apiKey: [] }],
      params: {
        type: 'object',
        properties: {
          id: {
            type: 'string',
            description: 'user id'
          }
        }
      },
      body: {
        type: 'object',
        properties: {
          hello: { type: 'string' },
          obj: {
            type: 'object',
            properties: {
              some: { type: 'string' }
            }
          }
        }
      },
      response: {
        201: {
          description: 'Succesful response',
          type: 'object',
          properties: {
            hello: { type: 'string' }
          }
        },
        default: {
          description: 'Default response',
          type: 'object',
          properties: {
            foo: { type: 'string' }
          }
        }
      }
    }
  }, (req, reply) => { reply.send({ hello: `Hello ${req.body.hello}` }) })

  fastify.post('/some-route/:id', {
    schema: {
      description: 'post some data',
      summary: 'qwerty',
      security: [{ apiKey: [] }],
      params: {
        type: 'object',
        properties: {
          id: {
            type: 'string',
            description: 'user id'
          }
        }
      },
      body: {
        type: 'object',
        properties: {
          hello: { type: 'string' },
          obj: {
            type: 'object',
            properties: {
              some: { type: 'string' }
            }
          }
        }
      },
      response: {
        201: {
          description: 'Succesful response',
          type: 'object',
          properties: {
            hello: { type: 'string' }
          }
        }
      }
    }
  }, (req, reply) => { reply.send({ hello: `Hello ${req.body.hello}` }) })

  // Use onReady hook instead of ready event to make sure the hook from
  // @fastify/swagger is triggered before this one
  fastify.addHook('onReady', () => {
    console.log(fastify.swagger())
  })
})

// Load all the plugins
fastify.ready()

This does:

  1. use await app.register() so that the code that follows has the onReady hook correctly setup
  2. use app.addHook('onReady') instead of .ready(), which only make sense top-level, which is the partial source of the confusion
  3. calls app.ready() after registering the plugin to start everything.
mcollina commented 9 months ago

I think we should update the README.

PavelPolyakov commented 5 months ago

Hi @mcollina ,

Thanks for the clarification. I'm not sure I got it completely though. My question — can there be such @fastify/swagger integration, that it's done in non-sync way for the main fastify instance configuration and still work with the routes which are declared before/after it?

Here is an example, based on what you've showcased here, but I put fastify.post outside of the plugin:

import Fastify from "fastify";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUI from "@fastify/swagger-ui";

const fastify = Fastify()

fastify.post('/some-route/:id', {
  schema: {
    description: 'post some data',
    tags: ['user', 'code'],
    summary: 'qwerty',
    security: [{ apiKey: [] }],
    params: {
      type: 'object',
      properties: {
        id: {
          type: 'string',
          description: 'user id'
        }
      }
    },
    body: {
      type: 'object',
      properties: {
        hello: { type: 'string' },
        obj: {
          type: 'object',
          properties: {
            some: { type: 'string' }
          }
        }
      }
    },
    response: {
      201: {
        description: 'Succesful response',
        type: 'object',
        properties: {
          hello: { type: 'string' }
        }
      },
      default: {
        description: 'Default response',
        type: 'object',
        properties: {
          foo: { type: 'string' }
        }
      }
    }
  }
}, (req, reply) => { reply.send({ hello: `Hello ${req.body.hello}` }) })

fastify.register(async function (fastify) {
  // await is necessary here to make sure its onReady hook is registered
  // before the one of this module
  await fastify.register(fastifySwagger, {
    openapi: {
      info: {
        title: 'Test swagger',
        description: 'testing the fastify swagger api',
        version: '0.1.0'
      },
      servers: [{
        url: 'http://localhost'
      }],
      components: {
        securitySchemes: {
          apiKey: {
            type: 'apiKey',
            name: 'apiKey',
            in: 'header'
          }
        }
      }
    },
    hideUntagged: true,
    exposeRoute: true
  })

  fastify.put('/some-route/:id', {
    schema: {
      description: 'post some data',
      tags: ['user', 'code'],
      summary: 'qwerty',
      security: [{ apiKey: [] }],
      params: {
        type: 'object',
        properties: {
          id: {
            type: 'string',
            description: 'user id'
          }
        }
      },
      body: {
        type: 'object',
        properties: {
          hello: { type: 'string' },
          obj: {
            type: 'object',
            properties: {
              some: { type: 'string' }
            }
          }
        }
      },
      response: {
        201: {
          description: 'Succesful response',
          type: 'object',
          properties: {
            hello: { type: 'string' }
          }
        },
        default: {
          description: 'Default response',
          type: 'object',
          properties: {
            foo: { type: 'string' }
          }
        }
      }
    }
  }, (req, reply) => { reply.send({ hello: `Hello ${req.body.hello}` }) })

  await fastify.register(fastifySwaggerUI, {
    routePrefix: '/documentation',
    uiConfig: {
      docExpansion: 'full',
      deepLinking: false
    },
    uiHooks: {
      onRequest: function (request, reply, next) { next() },
      preHandler: function (request, reply, next) { next() }
    },
    staticCSP: true,
    transformStaticCSP: (header) => header,
    transformSpecification: (swaggerObject, request, reply) => { return swaggerObject },
    transformSpecificationClone: true
  })

  // Use onReady hook instead of ready event to make sure the hook from
  // @fastify/swagger is triggered before this one
  fastify.addHook('onReady', () => {
    console.log(fastify.swagger())
  })
})

// Load all the plugins
fastify.ready()

// Load all the plugins
await fastify.listen({ port: parseInt(process.env.PORT) || 3030 });

When I run node index.mjs, then only PUT route is available in Swagger UI

image

Other words, how can I properly register @fastify/swagger, that it catches all the routes, in case I work with the runtime without top level await?

Thanks

mcollina commented 5 months ago

This is expected. Moving it outside of the plugin would make it impossible for swagger to listen to the events. It's what enable us to have multiple instance of swagger in the same app.

RicardoViteriR commented 4 months ago

@PavelPolyakov hi, you need to use fastify-plugin to make your example work. This way, the swagger plugin is available to other contexts.

Here is how I've setup my application (I use @scalar/fastify-api-reference for the UI):

./app.ts


import Fastify, { FastifyInstance } from "fastify";
import { privateContext } from "./modules/private";
import { publicContext } from "./modules/public";
import apiDocs from "./plugins/fastify-swagger/swagger";

export async function buildApp(): Promise<any> {
  const app: FastifyInstance = Fastify({
    trustProxy: true,
  });

  await app.register(apiDocs)

  app.register(publicContext)
  app.register(privateContext)

  app.ready()

  return app
}


./plugins/fastify-swagger/swagger.ts

import fp from 'fastify-plugin';

async function apiDocs(fastify: any, options: any) {
    await fastify.register(require('@fastify/swagger'), {
        swagger: {
            info: {
                title: 'Test swagger',
                description: 'Swagger API',
                version: '0.1.0'
            },
            externalDocs: {
                url: 'https://swagger.io',
                description: 'Find more info here'
            },
            host: 'localhost',
            schemes: ['http'],
            consumes: ['application/json'],
            produces: ['application/json'],
            tags: [
                { name: 'user', description: 'User related end-points' },
                { name: 'code', description: 'Code related end-points' }
            ],
            definitions: {
                User: {
                    type: 'object',
                    required: ['id', 'email'],
                    properties: {
                        id: { type: 'string', format: 'uuid' },
                        firstName: { type: 'string' },
                        lastName: { type: 'string' },
                        email: { type: 'string', format: 'email' }
                    }
                }
            },
            securityDefinitions: {
                apiKey: {
                    type: 'apiKey',
                    name: 'apiKey',
                    in: 'header'
                }
            }
        }
    })

    await fastify.register(require('@scalar/fastify-api-reference'), {
        routePrefix: '/reference',
        configuration: {
            theme: 'default',
        },

    })

    fastify.addHook('onReady', () => {
        console.log(fastify.swagger())
    })

}

export default fp(apiDocs, { name: 'Swagger' });


./modules/public/index.ts

import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { businessStatistics } from "./statistics";

export async function publicContext(app: FastifyInstance) {
    app.register(async function publicContext(app: any) {
        app.register(businessStatistics)

    })

}