Spectralを利用して、OpenAPI, SwaggerのコードをLintする

# Spectralを利用して、OpenAPI, SwaggerのコードをLintする

[Spectral](https://docs.stoplight.io/docs/spectral/674b27b261c3c-overview)は、
OpenAPI(v2, v3)やAsyncAPIのlinterです。

Custom Ruleの生成が比較的簡単にできる点、swagger-uiを利用している場合にinvalidケースなどに気づきにくいことが多々あり、利用するメリットがあるかと思います。
[Typespec](https://typespec.io/)などを利用している時に、噛み合わせが悪いコードをlintで制約を追加したりしてバグを予防したりすることができます。

## 0. Before do

## 0-0. Project Structure

想定しているディレクトリ構成とファイルの中身

```terminal
tree . -I node_modules
.
├── README.md
├── compose.yaml
├── dockerfile
├── openapi3
│   ├── generated
│   │   └── @typespec
│   │       └── openapi3
│   │           └── openapi.yaml
├── package-lock.json
├── package.json
```

<details>
<summary>compose.yaml</summary>

```compose.yaml
services:
  http_schema_container:
    container_name: http_schema_container
    build:
      context: .
      dockerfile: dockerfile
    tty: true
    restart: always
    volumes:
      - .:/usr/src/app
```

</details>

<details>
<summary>dockerfile</summary>

```dockerfile
FROM node:23-alpine3.19

WORKDIR /usr/src/app
```

</details>

<details>
<summary>openapi3/generated/@typespec/openapi3/openapi.yaml</summary>

```openapi3/generated/@typespec/openapi3/openapi.yaml
openapi: 3.0.0
info:
  title: (title)
  version: 0.0.0
tags: []
paths:
  /pets:
    get:
      operationId: Pets_list
      parameters:
        - name: filter
          in: query
          required: true
          schema:
            type: string
          explode: false
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Pet'
    post:
      operationId: Pets_create
      parameters: []
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Pet'
  /pets/{id}:
    get:
      operationId: Pets_read
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
  /stores:
    get:
      operationId: Stores_list
      parameters:
        - name: filter
          in: query
          required: true
          schema:
            type: string
          explode: false
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Store'
  /stores/{id}:
    get:
      operationId: Stores_read
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Store'
components:
  schemas:
    Address:
      type: object
      required:
        - street
        - city
      properties:
        street:
          type: string
        city:
          type: string
    Pet:
      type: object
      required:
        - name
        - age
      properties:
        name:
          type: string
        age:
          type: integer
          format: int32
    Store:
      type: object
      required:
        - name
        - address
      properties:
        name:
          type: string
        address:
          $ref: '#/components/schemas/Address'

```

</details>


## 0-1. typespec を利用して、OpenAPIのコードを生成する


```terminal
tree . -I node_modules
.
├── README.md
├── compose.yaml
├── dockerfile
├── openapi3
│   ├── generated
│   │   └── @typespec
│   │       └── openapi3
│   │           └── openapi.yaml
│   ├── main.tsp
│   └── tspconfig.yaml
├── package-lock.json
├── package.json
```

<details>
<summary>openapi3/main.tsp</summary>

```openapi3/main.tsp
import "@typespec/http";

using TypeSpec.Http;

model Pet {
  name: string;
  age: int32;
}

model Store {
  name: string;
  address: Address;
}

model Address {
  street: string;
  city: string;
}

@route("/pets")
interface Pets {
  list(@query filter: string): Pet[];
  create(@body pet: Pet): Pet;
  read(@path id: string): Pet;
}

@route("/stores")
interface Stores {
  list(@query filter: string): Store[];
  read(@path id: string): Store;
}

```

</details>

<details>
<summary>openapi3/tspconfig.yaml</summary>

```openapi3/tspconfig.yaml
warn-as-error: true                           # Treat warnings as errors
output-dir: "{cwd}/openapi3/generated"       # Configure the base output directory for all emitters
emit:
  - "@typespec/openapi3"
```

</details>

# 1. Spectralの依存関係を設定する

```terminal
% npm install @stoplight/spectral-cli
% echo 'extends: ["spectral:oas"]' > .spectral.yaml
```

```package.json
"scripts": {
    "cli:spectral": "spectral"
}
```

```terminal
tree . -I node_modules
.
├── .spectral.yaml
├── README.md
├── compose.yaml
├── dockerfile
├── openapi3
│   ├── generated
│   │   └── @typespec
│   │       └── openapi3
│   │           └── openapi.yaml
├── package-lock.json
├── package.json
```

# 2. SpectralのLintを実施する

```package.json
"scripts": {
    "lint:openapi": "spectral lint ./openapi3/generated/@typespec/openapi3/openapi.yaml"
}
```

```terminal
%  npm run lint:openapi
> app@0.1.0 lint:openapi
> spectral lint ./openapi3/generated/@typespec/openapi3/openapi.yaml

/usr/src/app/openapi3/generated/@typespec/openapi3/openapi.yaml
  1:1   warning  oas3-api-servers       OpenAPI "servers" must be present and non-empty array.
  2:6   warning  info-contact           Info object must have "contact" object.                        info
  2:6   warning  info-description       Info "description" must be present and non-empty string.       info
  8:9   warning  operation-description  Operation "description" must be present and non-empty string.  paths./pets.get
  8:9   warning  operation-tags         Operation must have non-empty "tags" array.                    paths./pets.get
 26:10  warning  operation-description  Operation "description" must be present and non-empty string.  paths./pets.post
 26:10  warning  operation-tags         Operation must have non-empty "tags" array.                    paths./pets.post
  43:9  warning  operation-description  Operation "description" must be present and non-empty string.  paths./pets/{id}.get
  43:9  warning  operation-tags         Operation must have non-empty "tags" array.                    paths./pets/{id}.get
  59:9  warning  operation-description  Operation "description" must be present and non-empty string.  paths./stores.get
  59:9  warning  operation-tags         Operation must have non-empty "tags" array.                    paths./stores.get
  78:9  warning  operation-description  Operation "description" must be present and non-empty string.  paths./stores/{id}.get
  78:9  warning  operation-tags         Operation must have non-empty "tags" array.                    paths./stores/{id}.get

✖ 13 problems (0 errors, 13 warnings, 0 infos, 0 hints)
```