diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index ff82a45..8b11512 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -83,6 +83,7 @@ function nav(): DefaultTheme.NavItem[] { { text: 'Cookies', link: '/guide/cookies' }, { text: 'Retries', link: '/guide/retries' }, { text: 'Dynamic Backend URL', link: '/guide/dynamic-backend-url' }, + { text: 'OpenAPI Types', link: '/guide/openapi-types' }, ], }, ], @@ -143,6 +144,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] { { text: 'Cookies', link: '/guide/cookies' }, { text: 'Retries', link: '/guide/retries' }, { text: 'Dynamic Backend URL', link: '/guide/dynamic-backend-url' }, + { text: 'OpenAPI Types', link: '/guide/openapi-types' }, ], }, { diff --git a/docs/config/index.md b/docs/config/index.md index 06afece..1fc1c6f 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -25,6 +25,8 @@ Main module configuration for your API endpoints. Each key represents an endpoin - `headers`: Headers to send with each request (optional) - `cookies`: Whether to send cookies with each request (optional) - `allowedUrls`: A list of allowed URLs to change the [backend URL at runtime](/guide/dynamic-backend-url) (optional) +- `schema`: A URL, file path, object, or async function pointing to an [OpenAPI Schema](https://swagger.io/resources/open-api) used to [generate types](/guide/openapi-types) (optional) +- `openAPITS`: [Configuration options](https://openapi-ts.pages.dev/node/#options) for `openapi-typescript`. Options defined here will override the global `openAPITS` ::: info The composables are generated based on your API endpoint ID. For example, if you were to call an endpoint `jsonPlaceholder`, the composables will be called `useJsonPlaceholderData` and `$jsonPlaceholder`. @@ -35,17 +37,18 @@ Default value: `{}` **Type** ```ts -type ApiPartyEndpoints = Record< - string, - { - url: string - token?: string - query?: QueryObject - headers?: Record - cookies?: boolean - allowedUrls?: string[] - } -> | undefined +interface Endpoint { + url: string + token?: string + query?: QueryObject + headers?: Record + cookies?: boolean + allowedUrls?: string[] + schema?: string | URL | OpenAPI3 | (() => Promise) + openAPITS?: OpenAPITSOptions +} + +type ApiPartyEndpoints = Record | undefined ``` **Example** @@ -65,8 +68,17 @@ export default defineNuxtConfig({ headers: { Authorization: `Basic ${Buffer.from(`${process.env.CMS_API_USERNAME}:${process.env.CMS_API_PASSWORD}`).toString('base64')}` } + }, + // Will generate `$petStore` and `usePetStore` as well as types for each path + petStore: { + url: process.env.PET_STORE_API_BASE_URL!, + schema: `${process.env.PET_STORE_API_BASE_URL!}/openapi.json` } } } }) ``` + +## `apiParty.openAPITS` + +The global [configuration options](https://openapi-ts.pages.dev/node/#options) for `openapi-typescript`. Options set here will be applied to every endpoint schema, but can be overridden by individual endpoint options. diff --git a/docs/guide/openapi-types.md b/docs/guide/openapi-types.md new file mode 100644 index 0000000..e95ce20 --- /dev/null +++ b/docs/guide/openapi-types.md @@ -0,0 +1,173 @@ +# OpenAPI Types + +If your API has an [OpenAPI](https://swagger.io/resources/open-api/) schema, Nuxt API Party can use it to generate types for you. These include path names, supported HTTP methods, request body, response body, query parameters, and headers. + +Usage of this feature requires [`openapi-typescript`](https://www.npmjs.com/package/openapi-typescript) to be installed. This library generates TypeScript definitions from your OpenAPI schema file. + +Install it before proceeding: + +::: code-group + +```bash [pnpm] +pnpm add -D openapi-typescript +``` + +```bash [yarn] +yarn add -D openapi-typescript +``` + +```bash [npm] +npm install -D openapi-typescript +``` + +::: + +## Schema Generation + +Some web frameworks can generate an OpenAPI schema for you based on your configured routes. Some examples include: + +- [NestJS](https://docs.nestjs.com/openapi/introduction) +- [FastAPI](https://fastapi.tiangolo.com/) +- [Django](https://www.django-rest-framework.org/api-guide/schemas/) +- [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/tutorials/web-api-help-pages-using-swagger) +- [Spring](https://springdoc.org/) +- [Utopia](https://docs.rs/utoipa/latest/utoipa/) + +If your framework doesn't directly support it, there may also be an additional library that does. + +::: info +If your API or framework uses the older OpenAPI 2.0 (aka Swagger) specification, you will need to install `openapi-typescript@5`, which is the latest version that supports it. +::: + +## Configuring the schema + +To take advantage of these type features, add the `schema` property to your endpoint config. It should be set to a file path or URL of the OpenAPI schema or an async function returning the parsed OpenAPI schema. The file can be in either JSON or YAML format. + +The following schema will be used for the code examples on this page. + +```yaml +# `schemas/myApi.yaml` +openapi: 3.0.0 +info: + title: My API + version: 0.1.0 +paths: + /foo: + get: + operationId: getFoos + responses: + 200: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Foo' + post: + operationId: createFoo + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Foo' + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/Foo' + /foo/{id}: + get: + operationId: getFoo + parameters: + - name: id + in: path + type: number + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/Foo' +components: + schemas: + Foo: + type: object + items: + id: + type: number + bar: + type: string + required: + - bar +``` + +Reference the schema file in your endpoint config: + +```ts +// `nuxt.config.ts` +export default defineNuxtConfig({ + apiParty: { + myApi: { + url: process.env.MY_API_API_BASE_URL!, + schema: './schemas/myApi.yaml', + }, + }, +}) +``` + +## Using the Types + +For most usages, no further intervention is needed. Nuxt API Party will use the types generated from this config to infer the correct types automatically when `$myApi` and `useMyApiData` is used. + +However, there may be a few things you may want to do now that you have type information. + +### Extract the Response Body Type + +You can get the request and response bodies directly from the exported `components` interface of the virtual module containing the types. + +Using the schema above: + +```ts +import { components } from '#nuxt-api-party/myApi' + +// { id?: number; foo: string } +type Foo = components['schemas']['Foo'] +``` + +### Use OpenAPI Defined Path Parameters + +OpenAPI can define path parameters on some endpoints. They are declared as `/foo/{id}`. Unfortunately, the endpoint is not defined as `/foo/10`, so using that as the path will break type inference. + +To get around this, set an object of the parameters to the property `pathParams`. You can then use the declared path for type inference, and the type checker will ensure you provide all required path parameters. The parameters will be interpolated into the path before the request is made. + +```ts +const data = await $myApi('foo/{id}', { + pathParams: { + id: 10 + } +}) +``` + +::: warning +Issues will **NOT** be reported at runtime by Nuxt API Party if the wrong parameters are used. The **incomplete** path will be sent to the backend **AS IS**. +::: + +### Route Method Overloading + +Some routes may be overloaded with multiple HTTP methods. The typing supports this natively and chooses the type based on the `method` property. When the property is omitted, the typing is smart enough to know `GET` is the default. + +In the example schema, `GET /foo` will return a `Foo[]` array, but `POST /foo` will return a `Foo` object. + +```ts +// resolved type: `{ id?: number; bar: string }[]` +const result1 = await $myApi('foo') + +// resolved type: `{ id?: number; bar: string }` +const result = await $myApi('foo', { + method: 'POST', + body: { + bar: 'string' + } +}) +``` diff --git a/package.json b/package.json index 8470ff2..633cafd 100644 --- a/package.json +++ b/package.json @@ -52,9 +52,13 @@ "defu": "^6.1.2", "ofetch": "^1.1.1", "ohash": "^1.1.3", + "pathe": "^1.1.1", "scule": "^1.0.0", "ufo": "^1.2.0" }, + "optionalDependencies": { + "openapi-typescript": "5.x || 6.x" + }, "devDependencies": { "@antfu/eslint-config": "^0.39.8", "@nuxt/module-builder": "^0.4.0", diff --git a/playground/.env.example b/playground/.env.example index f7c5935..a4d08d2 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -1 +1,2 @@ JSON_PLACEHOLDER_BASE_URL=https://jsonplaceholder.typicode.com +PET_STORE_BASE_URL=https://petstore3.swagger.io/api/v3 diff --git a/playground/app.vue b/playground/app.vue index 31236bf..25eb9d5 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -1,63 +1,3 @@ - - diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 8d0962b..e7f8d04 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -6,12 +6,16 @@ export default defineNuxtConfig({ jsonPlaceholder: { url: process.env.JSON_PLACEHOLDER_BASE_URL!, }, + petStore: { + url: process.env.PET_STORE_BASE_URL!, + schema: './schemas/petStore.json', + }, }, }, typescript: { // TODO: Re-enable when test directory can be excluded from type checking - // typeCheck: true, + // typeCheck: 'build, shim: false, tsConfig: { compilerOptions: { diff --git a/playground/pages/index.vue b/playground/pages/index.vue new file mode 100644 index 0000000..cc73726 --- /dev/null +++ b/playground/pages/index.vue @@ -0,0 +1,15 @@ + diff --git a/playground/pages/jsonPlaceholder.vue b/playground/pages/jsonPlaceholder.vue new file mode 100644 index 0000000..758d0b7 --- /dev/null +++ b/playground/pages/jsonPlaceholder.vue @@ -0,0 +1,88 @@ + + + diff --git a/playground/pages/petStore.vue b/playground/pages/petStore.vue new file mode 100644 index 0000000..664cf11 --- /dev/null +++ b/playground/pages/petStore.vue @@ -0,0 +1,93 @@ + + + diff --git a/playground/schemas/petStore.json b/playground/schemas/petStore.json new file mode 100644 index 0000000..f2a33f1 --- /dev/null +++ b/playground/schemas/petStore.json @@ -0,0 +1,1225 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Swagger Petstore - OpenAPI 3.0", + "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "email": "apiteam@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.17" + }, + "externalDocs": { + "description": "Find out more about Swagger", + "url": "http://swagger.io" + }, + "servers": [ + { + "url": "/api/v3" + } + ], + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "http://swagger.io" + } + }, + { + "name": "store", + "description": "Access to Petstore orders", + "externalDocs": { + "description": "Find out more about our store", + "url": "http://swagger.io" + } + }, + { + "name": "user", + "description": "Operations about user" + } + ], + "paths": { + "/pet": { + "put": { + "tags": [ + "pet" + ], + "summary": "Update an existing pet", + "description": "Update an existing pet by Id", + "operationId": "updatePet", + "requestBody": { + "description": "Update an existent pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + }, + "405": { + "description": "Validation exception" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Add a new pet to the store", + "description": "Add a new pet to the store", + "operationId": "addPet", + "requestBody": { + "description": "Create a new pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "405": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByStatus": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by status", + "description": "Multiple status values can be provided with comma separated strings", + "operationId": "findPetsByStatus", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": false, + "explode": true, + "schema": { + "type": "string", + "default": "available", + "enum": [ + "available", + "pending", + "sold" + ] + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid status value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByTags": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by tags", + "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + "operationId": "findPetsByTags", + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": false, + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid tag value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "pet" + ], + "summary": "Find pet by ID", + "description": "Returns a single pet", + "operationId": "getPetById", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to return", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "security": [ + { + "api_key": [] + }, + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Updates a pet in the store with form data", + "description": "", + "operationId": "updatePetWithForm", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "name", + "in": "query", + "description": "Name of pet that needs to be updated", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Status of pet that needs to be updated", + "schema": { + "type": "string" + } + } + ], + "responses": { + "405": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "delete": { + "tags": [ + "pet" + ], + "summary": "Deletes a pet", + "description": "", + "operationId": "deletePet", + "parameters": [ + { + "name": "api_key", + "in": "header", + "description": "", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "400": { + "description": "Invalid pet value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}/uploadImage": { + "post": { + "tags": [ + "pet" + ], + "summary": "uploads an image", + "description": "", + "operationId": "uploadFile", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "additionalMetadata", + "in": "query", + "description": "Additional Metadata", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/store/inventory": { + "get": { + "tags": [ + "store" + ], + "summary": "Returns pet inventories by status", + "description": "Returns a map of status codes to quantities", + "operationId": "getInventory", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/store/order": { + "post": { + "tags": [ + "store" + ], + "summary": "Place an order for a pet", + "description": "Place a new order in the store", + "operationId": "placeOrder", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/store/order/{orderId}": { + "get": { + "tags": [ + "store" + ], + "summary": "Find purchase order by ID", + "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.", + "operationId": "getOrderById", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of order that needs to be fetched", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + } + }, + "delete": { + "tags": [ + "store" + ], + "summary": "Delete purchase order by ID", + "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors", + "operationId": "deleteOrder", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of the order that needs to be deleted", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + } + } + }, + "/user": { + "post": { + "tags": [ + "user" + ], + "summary": "Create user", + "description": "This can only be done by the logged in user.", + "operationId": "createUser", + "requestBody": { + "description": "Created user object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "default": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/createWithList": { + "post": { + "tags": [ + "user" + ], + "summary": "Creates list of users with given input array", + "description": "Creates list of users with given input array", + "operationId": "createUsersWithListInput", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "successful operation" + } + } + } + }, + "/user/login": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs user into the system", + "description": "", + "operationId": "loginUser", + "parameters": [ + { + "name": "username", + "in": "query", + "description": "The user name for login", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "The password for login in clear text", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "X-Rate-Limit": { + "description": "calls per hour allowed by the user", + "schema": { + "type": "integer", + "format": "int32" + } + }, + "X-Expires-After": { + "description": "date in UTC when token expires", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/xml": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid username/password supplied" + } + } + } + }, + "/user/logout": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs out current logged in user session", + "description": "", + "operationId": "logoutUser", + "parameters": [], + "responses": { + "default": { + "description": "successful operation" + } + } + } + }, + "/user/{username}": { + "get": { + "tags": [ + "user" + ], + "summary": "Get user by user name", + "description": "", + "operationId": "getUserByName", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be fetched. Use user1 for testing. ", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + } + }, + "put": { + "tags": [ + "user" + ], + "summary": "Update user", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that need to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Update an existent user in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "default": { + "description": "successful operation" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Delete user", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + } + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "petId": { + "type": "integer", + "format": "int64", + "example": 198772 + }, + "quantity": { + "type": "integer", + "format": "int32", + "example": 7 + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "description": "Order Status", + "example": "approved", + "enum": [ + "placed", + "approved", + "delivered" + ] + }, + "complete": { + "type": "boolean" + } + }, + "xml": { + "name": "order" + } + }, + "Customer": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 100000 + }, + "username": { + "type": "string", + "example": "fehguy" + }, + "address": { + "type": "array", + "xml": { + "name": "addresses", + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Address" + } + } + }, + "xml": { + "name": "customer" + } + }, + "Address": { + "type": "object", + "properties": { + "street": { + "type": "string", + "example": "437 Lytton" + }, + "city": { + "type": "string", + "example": "Palo Alto" + }, + "state": { + "type": "string", + "example": "CA" + }, + "zip": { + "type": "string", + "example": "94301" + } + }, + "xml": { + "name": "address" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1 + }, + "name": { + "type": "string", + "example": "Dogs" + } + }, + "xml": { + "name": "category" + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "username": { + "type": "string", + "example": "theUser" + }, + "firstName": { + "type": "string", + "example": "John" + }, + "lastName": { + "type": "string", + "example": "James" + }, + "email": { + "type": "string", + "example": "john@email.com" + }, + "password": { + "type": "string", + "example": "12345" + }, + "phone": { + "type": "string", + "example": "12345" + }, + "userStatus": { + "type": "integer", + "description": "User Status", + "format": "int32", + "example": 1 + } + }, + "xml": { + "name": "user" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "tag" + } + }, + "Pet": { + "required": [ + "name", + "photoUrls" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "name": { + "type": "string", + "example": "doggie" + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "type": "string", + "xml": { + "name": "photoUrl" + } + } + }, + "tags": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": [ + "available", + "pending", + "sold" + ] + } + }, + "xml": { + "name": "pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "xml": { + "name": "##default" + } + } + }, + "requestBodies": { + "Pet": { + "description": "Pet object that needs to be added to the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "UserArray": { + "description": "List of user object", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 846d090..b7790e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,19 @@ importers: ohash: specifier: ^1.1.3 version: 1.1.3 + pathe: + specifier: ^1.1.1 + version: 1.1.1 scule: specifier: ^1.0.0 version: 1.0.0 ufo: specifier: ^1.2.0 version: 1.2.0 + optionalDependencies: + openapi-typescript: + specifier: 5.x || 6.x + version: 6.4.5 devDependencies: '@antfu/eslint-config': specifier: ^0.39.8 @@ -501,6 +508,7 @@ packages: /@babel/highlight@7.22.10: resolution: {integrity: sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==} engines: {node: '>=6.9.0'} + requiresBuild: true dependencies: '@babel/helper-validator-identifier': 7.22.5 chalk: 2.4.2 @@ -1163,6 +1171,7 @@ packages: /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} + requiresBuild: true dependencies: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 @@ -1170,6 +1179,7 @@ packages: /@nodelib/fs.stat@2.0.5: resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} + requiresBuild: true /@nodelib/fs.walk@1.2.8: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} @@ -2352,7 +2362,7 @@ packages: /ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - dev: true + requiresBuild: true /ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} @@ -2373,6 +2383,7 @@ packages: /ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} + requiresBuild: true dependencies: color-convert: 1.9.3 @@ -2442,7 +2453,7 @@ packages: /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true + requiresBuild: true /array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} @@ -2603,9 +2614,9 @@ packages: /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} + requiresBuild: true dependencies: streamsearch: 1.1.0 - dev: true /c12@1.4.2: resolution: {integrity: sha512-3IP/MuamSVRVw8W8+CHWAz9gKN4gd+voF2zm/Ln6D25C2RhytEZ1ABbC8MjKr4BR9rhoV1JQ7jJA158LDiTkLg==} @@ -2671,6 +2682,7 @@ packages: /chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} + requiresBuild: true dependencies: ansi-styles: 3.2.1 escape-string-regexp: 1.0.5 @@ -2770,6 +2782,7 @@ packages: /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + requiresBuild: true dependencies: color-name: 1.1.3 @@ -2782,6 +2795,7 @@ packages: /color-name@1.1.3: resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + requiresBuild: true /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -3739,6 +3753,7 @@ packages: /fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + requiresBuild: true dependencies: reusify: 1.0.4 @@ -3764,6 +3779,7 @@ packages: /fill-range@7.0.1: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} + requiresBuild: true dependencies: to-regex-range: 5.0.1 @@ -4050,6 +4066,7 @@ packages: /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} + requiresBuild: true /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} @@ -4269,6 +4286,7 @@ packages: /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + requiresBuild: true /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} @@ -4292,6 +4310,7 @@ packages: /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + requiresBuild: true /is-path-inside@3.0.3: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} @@ -4349,13 +4368,13 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + requiresBuild: true /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true dependencies: argparse: 2.0.1 - dev: true /jsesc@0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} @@ -5163,7 +5182,6 @@ packages: supports-color: 9.4.0 undici: 5.23.0 yargs-parser: 21.1.1 - dev: true /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} @@ -5713,6 +5731,7 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + requiresBuild: true /radix3@1.0.1: resolution: {integrity: sha512-y+AcwZ3HcUIGc9zGsNVf5+BY/LxL+z+4h4J3/pp8jxSmy1STaCocPS3qrj4tA5ehUSzqtqK+0Aygvz/r/8vy4g==} @@ -5856,6 +5875,7 @@ packages: /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + requiresBuild: true /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} @@ -5904,6 +5924,7 @@ packages: /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + requiresBuild: true dependencies: queue-microtask: 1.2.3 @@ -6100,7 +6121,7 @@ packages: /streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} - dev: true + requiresBuild: true /string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} @@ -6175,6 +6196,7 @@ packages: /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} + requiresBuild: true dependencies: has-flag: 3.0.0 @@ -6188,7 +6210,7 @@ packages: /supports-color@9.4.0: resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} engines: {node: '>=12'} - dev: true + requiresBuild: true /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} @@ -6287,6 +6309,7 @@ packages: /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + requiresBuild: true dependencies: is-number: 7.0.0 @@ -6421,9 +6444,9 @@ packages: /undici@5.23.0: resolution: {integrity: sha512-1D7w+fvRsqlQ9GscLBwcAJinqcZGHUKjbOmXdlE/v8BvEGXjeWAax+341q44EuTcHXXnfyKNbKRq4Lg7OzhMmg==} engines: {node: '>=14.0'} + requiresBuild: true dependencies: busboy: 1.6.0 - dev: true /unenv@1.7.1: resolution: {integrity: sha512-iINrdDcqoAjGqoIeOW85TIfI13KGgW1VWwqNO/IzcvvZ/JGBApMAQPZhWcKhE5oC/woFSpCSXg5lc7r1UaLPng==} @@ -7094,7 +7117,6 @@ packages: /yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - dev: true /yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} diff --git a/src/module.ts b/src/module.ts index 75dcd4d..c947681 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,7 +1,21 @@ +import { relative } from 'pathe' import { defu } from 'defu' import { camelCase, pascalCase } from 'scule' -import { addImportsSources, addServerHandler, addTemplate, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit' +import { addImportsSources, addServerHandler, addTemplate, createResolver, defineNuxtModule, tryResolveModule, useLogger } from '@nuxt/kit' +import type { OpenAPI3, OpenAPITSOptions } from 'openapi-typescript' import type { QueryObject } from 'ufo' +import { generateTypes } from './openapi' + +export interface Endpoint { + url: string + token?: string + query?: QueryObject + headers?: Record + cookies?: boolean + allowedUrls?: string[] + schema?: string | URL | OpenAPI3 | (() => Promise) + openAPITS?: OpenAPITSOptions +} export interface ModuleOptions { /** @@ -32,17 +46,7 @@ export interface ModuleOptions { * * @default {} */ - endpoints?: Record< - string, - { - url: string - token?: string - query?: QueryObject - headers?: Record - cookies?: boolean - allowedUrls?: string[] - } - > + endpoints?: Record /** * Allow client-side requests besides server-side ones @@ -56,6 +60,11 @@ export interface ModuleOptions { * @default false */ allowClient?: boolean + + /** + * Global options for openapi-typescript + */ + openAPITS?: OpenAPITSOptions } export default defineNuxtModule({ @@ -69,9 +78,11 @@ export default defineNuxtModule({ defaults: { endpoints: {}, allowClient: false, + openAPITS: {}, }, - setup(options, nuxt) { - const logger = useLogger('nuxt-api-party') + async setup(options, nuxt) { + const moduleName = 'nuxt-api-party' + const logger = useLogger(moduleName) const getRawComposableName = (endpointId: string) => `$${camelCase(endpointId)}` const getDataComposableName = (endpointId: string) => `use${pascalCase(endpointId)}Data` @@ -109,17 +120,6 @@ export default defineNuxtModule({ const { resolve } = createResolver(import.meta.url) nuxt.options.build.transpile.push(resolve('runtime')) - // Inline server handler into Nitro bundle - // Needed to circumvent "cannot find module" error in `server.ts` for the `utils` import - nuxt.hook('nitro:config', (config) => { - config.externals = defu(config.externals, { - inline: [ - resolve('runtime/utils'), - resolve('runtime/formData'), - ], - }) - }) - // Add Nuxt server route to proxy the API request server-side addServerHandler({ route: '/api/__api_party/:endpointId', @@ -127,16 +127,37 @@ export default defineNuxtModule({ handler: resolve('runtime/server'), }) + nuxt.hook('nitro:config', (config) => { + // Inline server handler into Nitro bundle + // Needed to circumvent "cannot find module" error in `server.ts` for the `utils` import + config.externals ||= {} + config.externals.inline ||= [] + config.externals.inline.push(...[ + resolve('runtime/utils'), + resolve('runtime/formData'), + ]) + }) + const endpointKeys = Object.keys(resolvedOptions.endpoints) + // Nuxt will resolve the imports relative to the `srcDir`, so we can't use + // `#nuxt-api-party` with `declare module` pattern here addImportsSources({ - from: '#build/api-party', + from: resolve(nuxt.options.buildDir, `module/${moduleName}-imports.mjs`), imports: endpointKeys.flatMap(i => [getRawComposableName(i), getDataComposableName(i)]), }) - // Add generated composables + // Add `#nuxt-api-party` module alias for generated composables + nuxt.options.alias[`#${moduleName}`] = resolve(nuxt.options.buildDir, `module/${moduleName}-imports.mjs`) + + const relativeTo = (path: string) => relative( + resolve(nuxt.options.rootDir, nuxt.options.buildDir, 'module'), + resolve(path), + ) + + // Add module template for generated composables addTemplate({ - filename: 'api-party.mjs', + filename: `module/${moduleName}-imports.mjs`, getContents() { return ` import { _$api } from '${resolve('runtime/composables/$api')}' @@ -148,18 +169,60 @@ export const ${getDataComposableName(i)} = (...args) => _useApiData('${i}', ...a }, }) - // Add types for generated composables + const schemaEndpoints = Object.fromEntries( + Object.entries(resolvedOptions.endpoints) + .filter(([, endpoint]) => 'schema' in endpoint), + ) + const schemaEndpointIds = Object.keys(schemaEndpoints) + const hasOpenAPIPkg = await tryResolveModule('openapi-typescript', [nuxt.options.rootDir]) + + if (schemaEndpointIds.length && !hasOpenAPIPkg) { + logger.warn('OpenAPI types generation is enabled, but the `openapi-typescript` package is not found. Please install it to enable endpoint types generation.') + schemaEndpointIds.length = 0 + } + + // Use generated types for generated composables for + // (1) Nuxt auto-imports + // (2) global import from `#nuxt-api-party` addTemplate({ - filename: 'api-party.d.ts', + filename: `module/${moduleName}-imports.d.ts`, getContents() { return ` -import type { $Api } from '${resolve('runtime/composables/$api')}' -import type { UseApiData } from '${resolve('runtime/composables/useApiData')}' +// Generated by ${moduleName} +import type { $Api } from '${relativeTo('runtime/composables/$api')}' +import type { UseApiData } from '${relativeTo('runtime/composables/useApiData')}' + +${schemaEndpointIds.map(i => `import type { paths as ${pascalCase(i)}Paths } from '#${moduleName}/${i}'`).join('')} + ${endpointKeys.map(i => ` -export declare const ${getRawComposableName(i)}: $Api -export declare const ${getDataComposableName(i)}: UseApiData -`.trimStart()).join('')}`.trimStart() +export declare const ${getRawComposableName(i)}: $Api${schemaEndpointIds.includes(i) ? `<${pascalCase(i)}Paths>` : ''} +export declare const ${getDataComposableName(i)}: UseApiData${schemaEndpointIds.includes(i) ? `<${pascalCase(i)}Paths>` : ''} +`.trimStart()).join('').trimEnd()} +`.trimStart() }, }) + + // Add global `#nuxt-api-party` and OpenAPI endpoint types + addTemplate({ + filename: `module/${moduleName}.d.ts`, + async getContents() { + return ` +// Generated by ${moduleName} +declare module '#${moduleName}' { + export * from './${moduleName}-imports' +} + +${schemaEndpointIds.length + ? await generateTypes(schemaEndpoints, resolvedOptions.openAPITS) + : ''} +`.trimStart() + }, + }) + + // Add type references to TypeScript config + nuxt.hook('prepare:types', (options) => { + options.references.push({ path: resolve(nuxt.options.buildDir, `module/${moduleName}.d.ts`) }) + options.references.push({ path: resolve(nuxt.options.buildDir, `module/${moduleName}-imports.d.ts`) }) + }) }, }) diff --git a/src/openapi.ts b/src/openapi.ts new file mode 100644 index 0000000..43d81cc --- /dev/null +++ b/src/openapi.ts @@ -0,0 +1,69 @@ +import { resolve } from 'pathe' +import { useNuxt } from '@nuxt/kit' +import type { OpenAPI3, OpenAPITSOptions } from 'openapi-typescript' +import type { Endpoint } from './module' + +export async function generateTypes( + endpoints: Record, + globalOpenAPIOptions: OpenAPITSOptions, +) { + // Note: openapi-typescript uses `process.exit()` to handle errors + let runningCount = 0 + + process.on('exit', () => { + if (runningCount > 0) + throw new Error('Caught process.exit()') + }) + + const openapiTS = await import('openapi-typescript') + const schemas = await Promise.all( + Object.keys(endpoints).map(async (id) => { + let types = '' + + const { openAPITS: openAPIOptions = {} } = endpoints[id] + const schema = await resolveSchema(endpoints[id]) + runningCount++ + + try { + // @ts-expect-error: ESM import type mismatch + types = await openapiTS(schema, { + commentHeader: '', + ...globalOpenAPIOptions, + ...openAPIOptions, + }) + } + catch { + types = ` +export type paths = Record +export type webhooks = Record +export type components = Record +export type external = Record +export type operations = Record + `.trimStart() + } + finally { + runningCount-- + } + + return ` +declare module '#nuxt-api-party/${id}' { +${types.replace(/^/gm, ' ').trimEnd()} +}`.trimStart() + }), + ) + + return schemas.join('\n\n') +} + +async function resolveSchema({ schema }: Endpoint): Promise { + const nuxt = useNuxt() + + if (typeof schema === 'function') + return await schema() + + // Parse file path and fix it + if (typeof schema === 'string' && !schema.match(/^https?:\/\//)) + schema = resolve(nuxt.options.rootDir, schema) + + return schema! +} diff --git a/src/runtime/composables/$api.ts b/src/runtime/composables/$api.ts index ee9cdc2..5173c46 100644 --- a/src/runtime/composables/$api.ts +++ b/src/runtime/composables/$api.ts @@ -1,13 +1,18 @@ import { hash } from 'ohash' import type { NitroFetchOptions } from 'nitropack' -import { headersToObject, serializeMaybeEncodedBody } from '../utils' +import { headersToObject, resolvePath, serializeMaybeEncodedBody } from '../utils' import { isFormData } from '../formData' import type { ModuleOptions } from '../../module' import type { EndpointFetchOptions } from '../utils' +import type { APIRequestOptions, APIResponse, AllPaths, GETPlainPaths, HttpMethod, IgnoreCase, PathItemObject } from '../types' import { useNuxtApp, useRequestHeaders, useRuntimeConfig } from '#imports' export type ApiFetchOptions = Omit, 'body' | 'cache'> & { + pathParams?: Record body?: string | Record | FormData | null +} + +export interface BaseApiFetchOptions { /** * Skip the Nuxt server proxy and fetch directly from the API. * Requires `allowClient` to be enabled in the module options as well. @@ -21,23 +26,47 @@ export type ApiFetchOptions = Omit, 'body' | 'cache'> cache?: boolean } -export type $Api = ( +type UntypedAPI = ( path: string, opts?: ApiFetchOptions, ) => Promise +interface $OpenAPI> { +

>( + path: P + ): Promise> +

, M extends IgnoreCase>( + path: P, + opts?: BaseApiFetchOptions & APIRequestOptions + ): Promise]>> +} + +export type $Api = never> = [Paths] extends [never] + ? UntypedAPI + : $OpenAPI + export function _$api( endpointId: string, path: string, - opts: ApiFetchOptions = {}, + opts: ApiFetchOptions & BaseApiFetchOptions = {}, ): Promise { const nuxt = useNuxtApp() const promiseMap = (nuxt._promiseMap = nuxt._promiseMap || new Map()) as Map> - const { query, headers, method, body, client = false, cache = false, ...fetchOptions } = opts + const { + pathParams, + query, + headers, + method, + body, + client = false, + cache = false, + ...fetchOptions + } = opts const { apiParty } = useRuntimeConfig().public const key = `$party${hash([ endpointId, path, + pathParams, query, method, ...(isFormData(body) ? [] : [body]), @@ -55,7 +84,7 @@ export function _$api( const endpoints = (apiParty as unknown as ModuleOptions).endpoints || {} const endpoint = endpoints[endpointId] - const clientFetcher = () => globalThis.$fetch(path, { + const clientFetcher = () => globalThis.$fetch(resolvePath(path, pathParams), { ...fetchOptions, baseURL: endpoint.url, method, @@ -76,7 +105,7 @@ export function _$api( ...fetchOptions, method: 'POST', body: { - path, + path: resolvePath(path, pathParams), query, headers: { ...headersToObject(headers), diff --git a/src/runtime/composables/useApiData.ts b/src/runtime/composables/useApiData.ts index 4baa17a..8fefb54 100644 --- a/src/runtime/composables/useApiData.ts +++ b/src/runtime/composables/useApiData.ts @@ -5,9 +5,10 @@ import type { NitroFetchOptions } from 'nitropack' import type { WatchSource } from 'vue' import type { AsyncData, AsyncDataOptions } from 'nuxt/app' import type { ModuleOptions } from '../../module' -import { headersToObject, serializeMaybeEncodedBody, toValue } from '../utils' +import { headersToObject, resolvePath, serializeMaybeEncodedBody, toValue } from '../utils' import { isFormData } from '../formData' import type { EndpointFetchOptions, MaybeRef, MaybeRefOrGetter } from '../utils' +import type { APIError, APIRequestOptions, APIResponse, AllPaths, GETPlainPaths, HttpMethod, IgnoreCase, PathItemObject } from '../types' import { useAsyncData, useRequestHeaders, useRuntimeConfig } from '#imports' type ComputedOptions> = { @@ -18,51 +19,69 @@ type ComputedOptions> = { : MaybeRef; } -export type UseApiDataOptions = - Omit, 'watch'> - & Pick< - ComputedOptions>, - | 'onRequest' - | 'onRequestError' - | 'onResponse' - | 'onResponseError' - | 'query' - | 'headers' - | 'method' - | 'retry' - > - & { - body?: MaybeRef | FormData | null | undefined> - /** - * Skip the Nuxt server proxy and fetch directly from the API. - * Requires `allowClient` to be enabled in the module options as well. - * @default false - */ - client?: boolean - /** - * Cache the response for the same request - * @default true - */ - cache?: boolean - /** - * Watch an array of reactive sources and auto-refresh the fetch result when they change. - * Fetch options and URL are watched by default. You can completely ignore reactive sources by using `watch: false`. - */ - watch?: (WatchSource | object)[] | false - } +export type BaseApiDataOptions = Omit, 'watch'> & { + /** + * Skip the Nuxt server proxy and fetch directly from the API. + * Requires `allowClient` to be enabled in the module options as well. + * @default false + */ + client?: boolean + /** + * Cache the response for the same request + * @default true + */ + cache?: boolean + /** + * Watch an array of reactive sources and auto-refresh the fetch result when they change. + * Fetch options and URL are watched by default. You can completely ignore reactive sources by using `watch: false`. + */ + watch?: (WatchSource | object)[] | false +} + +export type UseOpenAPIDataOptions< + P extends PathItemObject, + M extends IgnoreCase, +> = BaseApiDataOptions]>> & ComputedOptions> + +export type UseApiDataOptions = BaseApiDataOptions & Pick< + ComputedOptions>, + | 'onRequest' + | 'onRequestError' + | 'onResponse' + | 'onResponseError' + | 'query' + | 'headers' + | 'method' + | 'retry' +> & { + pathParams?: MaybeRef> + body?: MaybeRef | FormData | null | undefined> +} -export type UseApiData = ( +export type UntypedUseApiData = ( path: MaybeRefOrGetter, opts?: UseApiDataOptions, ) => AsyncData +export interface UseOpenAPIData> { +

>( + path: MaybeRefOrGetter

+ ): AsyncData, APIError> +

, M extends IgnoreCase>( + path: MaybeRefOrGetter

, + opts?: UseOpenAPIDataOptions, + ): AsyncData]>, APIError]>> +} +export type UseApiData = never> = [Paths] extends [never] + ? UntypedUseApiData + : UseOpenAPIData + export function _useApiData( endpointId: string, path: MaybeRefOrGetter, opts: UseApiDataOptions = {}, ) { const { apiParty } = useRuntimeConfig().public - const _path = computed(() => toValue(path)) const { server, lazy, @@ -71,6 +90,7 @@ export function _useApiData( pick, watch, immediate, + pathParams, query, headers, method, @@ -79,6 +99,7 @@ export function _useApiData( cache = true, ...fetchOptions } = opts + const _path = computed(() => resolvePath(toValue(path), toValue(pathParams))) if (client && !apiParty.allowClient) throw new Error('Client-side API requests are disabled. Set "allowClient: true" in the module options to enable them.') diff --git a/src/runtime/types.ts b/src/runtime/types.ts new file mode 100644 index 0000000..e0bd56a --- /dev/null +++ b/src/runtime/types.ts @@ -0,0 +1,80 @@ +import type { NitroFetchOptions } from 'nitropack' + +export type IgnoreCase = Lowercase | Uppercase +export type RemovePrefix = T extends `${P}${infer S}` ? S : never + +export type PathItemObject = { [M in HttpMethod]?: any } & { parameters?: any } + +// Constant types +export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace' +export type OkStatus = 200 | 201 | 202 | 203 | 204 | 206 | 207 | '2XX' | 'default' +export type ErrorStatus = 500 | '5XX' | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 429 | 431 | 444 | 450 | 451 | 497 | 498 | 499 | '4XX' + +// General purpose types +export type RequestBody = T extends { requestBody: { content: infer Body } } + ? + | (Body extends { 'application/octet-stream': any } + ? { + body: string | Blob + headers: { 'content-type': 'application/octet-stream' } + } + : Record) + | (Body extends { 'application/json': infer Schema } + ? { body: Schema; headers?: { 'content-type'?: 'application/json' } } + : Record) + | (Body extends { 'application/x-www-form-urlencoded': any } + ? { + body: FormData + headers: { 'content-type': 'application/x-www-form-urlencoded' } + } + : Record) + : unknown + +export type Method> = + 'get' extends Lowercase ? { method?: M } : { method: M } + +export type Param = T extends { + parameters: { [_ in N]?: any } +} + ? { [_ in keyof Pick as K]: T['parameters'][N] } + : unknown + +export type QueryParameters = Param +export type HeaderParameters = Param +export type PathParameters = Param + +export type APIRequestOptions< + P extends PathItemObject, + M extends IgnoreCase, +> = Omit< + NitroFetchOptions>, + 'params' | 'query' | 'headers' | 'body' | 'method' +> & RequestBody]> & PathParameters]> & QueryParameters]> & Method + +type MediaTypes = { + [S in Status]: T extends { + responses: { + [_ in S]: { + content: { + 'application/json': infer Model + } + }; + } + } + ? Model + : never; +}[Status] + +// Fetch types +export type APIResponse = MediaTypes +export type APIError = MediaTypes + +export type AllPaths> = + RemovePrefix +export type GETPlainPaths> = { + [P in keyof Paths]: Paths[P] extends { get: { parameters: infer P } } + ? P extends { query: any } | { header: any } | { path: any } + ? never + : RemovePrefix

+ : never; +}[keyof Paths] diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 86cefb3..1bb6a20 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -40,3 +40,16 @@ export async function deserializeMaybeEncodedBody(value: ApiFetchOptions['body'] return value } + +export function resolvePath(path: string, params?: Record) { + // To simplify typings, OpenAPI path parameters can be expanded here + if (params) { + return Object.entries(params).reduce( + (path, [name, value]) => + path.replace(`{${name}}`, encodeURIComponent(toValue(value))), + path, + ) + } + + return path +} diff --git a/test/fixture/nuxt.config.ts b/test/fixture/nuxt.config.ts index 29e02b8..a1589a1 100644 --- a/test/fixture/nuxt.config.ts +++ b/test/fixture/nuxt.config.ts @@ -12,7 +12,7 @@ export default defineNuxtConfig({ }, typescript: { - typeCheck: !isCI, + typeCheck: !isCI ? 'build' : false, shim: false, }, })