Skip to content

Commit

Permalink
feat: generate types from OpenAPI schemas (#33)
Browse files Browse the repository at this point in the history
* Prepare pages for multiple examples

* Generate types from OpenAPI schema
Resolves #32

* Save the petstore schema locally

* Tweak the openapi docs so paths are correct

* docs: update split `Endpoint` type

* docs: capitalize OpenAPI headlines

* chore: unify playground styling

* chore: format types

* refactor: outsource open api types generation into own file

* chore: formatting

* chore: add `pathe` dependency

* refactor: simplify `resolvePath`

* refactor: remove `pathParams` default val

* fix: add `pathParams` fallback back in

* chore: lint

* fix: Use more explicit type in RequestBody

* fix: Add petstore url to example env

* chore: Remove pointless cast

* refactor: use `Record<string, any>` instead of `object`

* fix: add default opts value back

* refactor: simplify if statement

---------

Co-authored-by: Johann Schopplich <[email protected]>
  • Loading branch information
mattmess1221 and johannschopplich authored Aug 18, 2023
1 parent 7a8790d commit 6876f40
Show file tree
Hide file tree
Showing 19 changed files with 2,021 additions and 185 deletions.
2 changes: 2 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
],
Expand Down Expand Up @@ -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' },
],
},
{
Expand Down
34 changes: 23 additions & 11 deletions docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -35,17 +37,18 @@ Default value: `{}`
**Type**

```ts
type ApiPartyEndpoints = Record<
string,
{
url: string
token?: string
query?: QueryObject
headers?: Record<string, string>
cookies?: boolean
allowedUrls?: string[]
}
> | undefined
interface Endpoint {
url: string
token?: string
query?: QueryObject
headers?: Record<string, string>
cookies?: boolean
allowedUrls?: string[]
schema?: string | URL | OpenAPI3 | (() => Promise<OpenAPI3>)
openAPITS?: OpenAPITSOptions
}

type ApiPartyEndpoints = Record<string, Endpoint> | undefined
```
**Example**
Expand All @@ -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.
173 changes: 173 additions & 0 deletions docs/guide/openapi-types.md
Original file line number Diff line number Diff line change
@@ -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'
}
})
```
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions playground/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
JSON_PLACEHOLDER_BASE_URL=https://jsonplaceholder.typicode.com
PET_STORE_BASE_URL=https://petstore3.swagger.io/api/v3
96 changes: 9 additions & 87 deletions playground/app.vue
Original file line number Diff line number Diff line change
@@ -1,101 +1,23 @@
<script setup lang="ts">
import type { FetchError } from 'ofetch'
import type { JsonPlaceholderComment } from './types'
const route = useRoute()
// Intended for similar use cases as `useFetch`
const { data, pending, error } = await useJsonPlaceholderData<JsonPlaceholderComment>(
'comments',
{
query: computed(() => ({
postId: `${route.query.postId || 1}`,
})),
onResponse({ response }) {
if (process.server)
return
// eslint-disable-next-line no-console
console.log(response._data)
},
},
)
// eslint-disable-next-line no-console
watch(error, value => console.log(value))
async function incrementPostId() {
await navigateTo({
query: {
postId: `${Number(route.query.postId || 1) + 1}`,
},
})
// eslint-disable-next-line no-console
console.log('Post ID:', route.query.postId)
}
const formResponse = ref()
// Intended for similar use cases as `$fetch`
async function onSubmit() {
try {
formResponse.value = await $jsonPlaceholder('posts', {
method: 'POST',
body: {
title: 'foo',
body: 'bar',
userId: 1,
},
})
// eslint-disable-next-line no-console
console.log('formResponse:', formResponse.value)
}
catch (e) {
console.error('statusCode:', (e as FetchError).statusCode)
console.error('statusMessage:', (e as FetchError).statusMessage)
console.error('data:', (e as FetchError).data)
}
}
</script>

<template>
<Head>
<Title>nuxt-api-party</Title>
<Link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@exampledev/[email protected]/new.min.css" />
</Head>

<header>
<h1>nuxt-api-party</h1>
<NuxtLink to="/">
<h1>nuxt-api-party</h1>
</NuxtLink>
<p>
Requests are proxied by a Nuxt server route and passed back to the client. The playground uses <a href="https://jsonplaceholder.typicode.com/">{JSON} Placeholder</a>
as an example API. The dynamic composables <code>$jsonPlaceholder</code> and <code>useJsonPlaceholderData</code> are generated by the module.
Requests are proxied by a Nuxt server route and passed back to the client.
The playground uses <a href="https://jsonplaceholder.typicode.com/">{JSON} Placeholder</a>
and <a href="https://petstore3.swagger.io/">Swagger Petstore</a> as example APIs.
The dynamic composables <code>$jsonPlaceholder</code> and <code>useJsonPlaceholderData</code>
are generated by the module.
</p>
</header>

<main>
<h2>$jsonPlaceholder</h2>
<p>Responses are <strong>not</strong> cached by default.</p>
<blockquote>(Imagine form fields here)</blockquote>
<p>
<button @click="onSubmit()">
Submit
</button>
</p>
<pre v-if="formResponse">{{ JSON.stringify(formResponse, undefined, 2) }}</pre>
<hr>

<h2>useJsonPlaceholderData</h2>
<p>Responses are cached by default.</p>
<p>
Status:
<mark v-if="pending">pending</mark>
<code v-else>fetched</code>
</p>
<p>
<button @click="incrementPostId()">
Increment Post ID
</button>
</p>
<pre>{{ JSON.stringify(data, undefined, 2) }}</pre>
<NuxtPage />
</main>
</template>
6 changes: 5 additions & 1 deletion playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading

0 comments on commit 6876f40

Please sign in to comment.