@shipworthy/openapi-validator 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +309 -2
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +12 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lint.d.ts +15 -0
- package/dist/lint.js +125 -0
- package/package.json +46 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shipworthy AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,310 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shipworthy/openapi-validator
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lint an OpenAPI 3.x document with [Spectral](https://github.com/stoplightio/spectral)'s
|
|
4
|
+
built-in `spectral:oas` ruleset, exposed as an [Effect](https://effect.website) service.
|
|
5
|
+
Pass a JSON string, a YAML string, or an already-parsed object. The linter prints
|
|
6
|
+
each finding to the console. It fails with a typed `LintError` when the document has
|
|
7
|
+
blocking problems: a duplicate `operationId`, a duplicate tag name, or an unresolved
|
|
8
|
+
`$ref`. It reports other style findings as warnings and does not fail on them. Use it
|
|
9
|
+
to gate a spec in CI or before code generation. It powers the `openapi lint` command
|
|
10
|
+
in `@shipworthy/openapi-cli`. If you want a command-line linter, use that CLI instead
|
|
11
|
+
of calling this package.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun add @shipworthy/openapi-validator effect@^4.0.0
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Requirements
|
|
20
|
+
|
|
21
|
+
- **Effect 4.** The package depends on `effect` `^4.0.0`. The public API is an Effect
|
|
22
|
+
`Context.Service` and an Effect `Schema` error. Add a compatible version to your
|
|
23
|
+
app so every package resolves to one Effect instance.
|
|
24
|
+
- **ESM only.** The package is `"type": "module"` and has no CommonJS build.
|
|
25
|
+
- **Node.js `>=22.19.0` or Bun.** The package ships compiled JavaScript and `.d.ts`
|
|
26
|
+
declarations in `dist/`. No TypeScript loader or bundler is needed. Its public
|
|
27
|
+
API is exported from `@shipworthy/openapi-validator`.
|
|
28
|
+
- These dependencies are installed with the package (you do not install them):
|
|
29
|
+
`@stoplight/spectral-core@1.24.0`, `@stoplight/spectral-formats@1.9.0`,
|
|
30
|
+
`@stoplight/spectral-parsers@1.0.5`, `@stoplight/spectral-rulesets@1.23.0`.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { Effect } from "effect";
|
|
36
|
+
import { Linter } from "@shipworthy/openapi-validator";
|
|
37
|
+
|
|
38
|
+
const spec = `openapi: 3.1.0
|
|
39
|
+
info:
|
|
40
|
+
title: Pets
|
|
41
|
+
version: 1.0.0
|
|
42
|
+
paths:
|
|
43
|
+
/pets:
|
|
44
|
+
get:
|
|
45
|
+
operationId: listPets
|
|
46
|
+
responses:
|
|
47
|
+
"200":
|
|
48
|
+
description: ok
|
|
49
|
+
/pets/{id}:
|
|
50
|
+
get:
|
|
51
|
+
operationId: listPets
|
|
52
|
+
responses:
|
|
53
|
+
"200":
|
|
54
|
+
description: ok
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
const program = Effect.gen(function* () {
|
|
58
|
+
const linter = yield* Linter;
|
|
59
|
+
yield* linter.lintDocument(spec, { sourceUrl: "file:///openapi.yaml" });
|
|
60
|
+
console.log("Spec is valid");
|
|
61
|
+
}).pipe(
|
|
62
|
+
Effect.catchTag("LintError", (error) =>
|
|
63
|
+
Effect.sync(() => {
|
|
64
|
+
for (const d of error.diagnostics) console.log(d.code, d.path.join("."));
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
Effect.provide(Linter.layer),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await Effect.runPromise(program);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Output (warnings go to `console.warn`, errors to `console.error`):
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
⚠ OpenAPI "servers" must be present and non-empty array.
|
|
77
|
+
⚠ Info object must have "contact" object. at #/info
|
|
78
|
+
...
|
|
79
|
+
⚠ Operation must define parameter "{id}" as expected by path "/pets/{id}". at #/paths//pets/{id}/get
|
|
80
|
+
✘ Every operation must have unique "operationId". at #/paths//pets/{id}/get/operationId
|
|
81
|
+
operation-operationId-unique paths./pets/{id}.get.operationId
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Only the duplicate `operationId` fails the run. The other findings are warnings.
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
The package exports two names: `Linter` and `LintError`.
|
|
89
|
+
|
|
90
|
+
### `Linter`
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
class Linter extends Context.Service<
|
|
94
|
+
Linter,
|
|
95
|
+
{
|
|
96
|
+
readonly lintDocument: (
|
|
97
|
+
source: string | Record<string, unknown>,
|
|
98
|
+
options?: {
|
|
99
|
+
readonly sourceUrl?: string;
|
|
100
|
+
readonly silent?: boolean;
|
|
101
|
+
readonly strict?: boolean;
|
|
102
|
+
},
|
|
103
|
+
) => Effect.Effect<void, LintError>;
|
|
104
|
+
}
|
|
105
|
+
>()("@shipworthy/openapi-validator/Linter") {
|
|
106
|
+
static readonly layer: Layer.Layer<Linter>;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- **`Linter.layer`**: a `Layer` with no requirements. It builds one configured
|
|
111
|
+
Spectral instance per mode (default and strict), and you can reuse them for many
|
|
112
|
+
`lintDocument` calls.
|
|
113
|
+
Provide it with `Effect.provide(Linter.layer)`.
|
|
114
|
+
- **`Linter.use(fn)`**: the standard `Context.Service` accessor. Use it for one-off
|
|
115
|
+
calls:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
await Effect.runPromise(
|
|
119
|
+
Linter.use((linter) => linter.lintDocument(source)).pipe(Effect.provide(Linter.layer)),
|
|
120
|
+
);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
#### `lintDocument(source, options?)`
|
|
124
|
+
|
|
125
|
+
| Parameter | Type | Default | Description |
|
|
126
|
+
| ------------------- | ----------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
127
|
+
| `source` | `string \| Record<string, unknown>` | (required) | The document. A string whose first non-whitespace character is `{` is parsed as JSON. Any other string is parsed as YAML. An object is linted as-is. |
|
|
128
|
+
| `options.sourceUrl` | `string` | `undefined` | The document's URI (for example `file:///abs/openapi.yaml` or `https://…/openapi.yaml`). Spectral uses it to identify the document when it resolves `$ref`s. |
|
|
129
|
+
| `options.silent` | `boolean` | `false` | Suppresses warning output. Errors are always printed. |
|
|
130
|
+
| `options.strict` | `boolean` | `false` | Keeps Spectral's original `spectral:oas` severities and fails on YAML/JSON syntax errors. See [Strict mode](#strict-mode). |
|
|
131
|
+
|
|
132
|
+
**Returns:** `Effect<void, LintError>`. The effect succeeds with `undefined` when no
|
|
133
|
+
blocking diagnostics are found, even if the linter printed warnings.
|
|
134
|
+
|
|
135
|
+
**Fails with `LintError` when any of the following is true:**
|
|
136
|
+
|
|
137
|
+
- The input is not an OpenAPI 3.x document: it is not a plain object with an `openapi`
|
|
138
|
+
field whose major version is `3`. Swagger 2.0, arrays, and `{}` are all rejected. The
|
|
139
|
+
error has `message: "Expected an OpenAPI 3.x document"` and one diagnostic,
|
|
140
|
+
`{ code: "unsupported-format", path: [] }`.
|
|
141
|
+
- At least one blocking diagnostic remains after filtering (see
|
|
142
|
+
[Rules](#rules-and-severities)). `message` is every diagnostic message joined with
|
|
143
|
+
`"\n"`.
|
|
144
|
+
- Building the document or running Spectral throws. `message` is the thrown error's
|
|
145
|
+
message, `diagnostics` is `[]`, and `cause` holds the original error.
|
|
146
|
+
|
|
147
|
+
**Console output:** the linter writes each finding through Effect's `Console` service.
|
|
148
|
+
Blocking diagnostics go to `Console.error` with the prefix `✘ `. Non-blocking ones
|
|
149
|
+
go to `Console.warn` with the prefix `⚠ `. A message ends with ` at #/<path>` when it
|
|
150
|
+
has a location. Output is colored red or yellow only when `process.stdout.isTTY` is
|
|
151
|
+
true and `NO_COLOR` is unset. To capture or redirect output, provide your own
|
|
152
|
+
`Console` (for example `TestConsole` from `effect/testing` in tests).
|
|
153
|
+
|
|
154
|
+
### `LintError`
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
class LintError extends Schema.TaggedError<LintError>()("LintError", {
|
|
158
|
+
message: Schema.String,
|
|
159
|
+
diagnostics: Schema.Array(
|
|
160
|
+
Schema.Struct({
|
|
161
|
+
code: Schema.Union([Schema.String, Schema.Finite]),
|
|
162
|
+
message: Schema.String,
|
|
163
|
+
path: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
166
|
+
cause: Schema.optional(Schema.Defect()),
|
|
167
|
+
}) {}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- `_tag`: `"LintError"`. Handle it with `Effect.catchTag("LintError", …)`.
|
|
171
|
+
- `diagnostics[].code`: the Spectral rule ID (for example
|
|
172
|
+
`"operation-operationId-unique"`, `"openapi-tags-uniqueness"`, or `"invalid-ref"`),
|
|
173
|
+
or `"unsupported-format"`.
|
|
174
|
+
- `diagnostics[].message`: the rule message plus ` at #/<path>` when a location exists.
|
|
175
|
+
- `diagnostics[].path`: JSON path segments into the document. Segments are strings,
|
|
176
|
+
including array indexes and status codes: `["tags", "1", "name"]`.
|
|
177
|
+
|
|
178
|
+
Example `diagnostics` for a duplicate tag and a missing schema reference:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
[
|
|
182
|
+
{
|
|
183
|
+
"code": "openapi-tags-uniqueness",
|
|
184
|
+
"message": "\"tags\" object contains duplicate tag name \"a\". at #/tags/1/name",
|
|
185
|
+
"path": ["tags", "1", "name"]
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"code": "invalid-ref",
|
|
189
|
+
"message": "'#/components/schemas/Missing' does not exist at #/paths//x/get/responses/200/content/application/json/schema/$ref",
|
|
190
|
+
"path": [
|
|
191
|
+
"paths",
|
|
192
|
+
"/x",
|
|
193
|
+
"get",
|
|
194
|
+
"responses",
|
|
195
|
+
"200",
|
|
196
|
+
"content",
|
|
197
|
+
"application/json",
|
|
198
|
+
"schema",
|
|
199
|
+
"$ref"
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Rules and severities
|
|
206
|
+
|
|
207
|
+
The linter extends Spectral's `spectral:oas` ruleset in its default ("recommended")
|
|
208
|
+
mode, restricted to the OpenAPI 3.x format. It changes Spectral's behavior in these
|
|
209
|
+
ways:
|
|
210
|
+
|
|
211
|
+
| Change | Effect |
|
|
212
|
+
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
213
|
+
| `oas3-schema` is **off** | The linter does not validate the document's structure against the OpenAPI JSON Schema, so partial documents can be linted. |
|
|
214
|
+
| `spectral:oas` rules that default to `error` are downgraded to **warn** | This applies to `path-params`, `array-items`, `no-$ref-siblings`, `oas3-server-variables`, `oas3-valid-media-example`, and `oas3-valid-schema-example`. They are printed but do not fail. |
|
|
215
|
+
| `operation-operationId-unique` keeps **error** severity | A duplicate `operationId` fails the run. |
|
|
216
|
+
| Parser option `incompatibleValues` is off | YAML numeric keys such as `200:` are accepted. |
|
|
217
|
+
|
|
218
|
+
Some findings are always reported as warnings, even when Spectral marks them as errors:
|
|
219
|
+
|
|
220
|
+
- `parser` diagnostics, such as YAML or JSON syntax errors. A malformed YAML string is
|
|
221
|
+
printed as warnings and does **not** fail the run.
|
|
222
|
+
- `invalid-ref` diagnostics whose message starts with `ReadError:` and whose path
|
|
223
|
+
includes `examples`. These are example payloads that use `$ref` as data.
|
|
224
|
+
|
|
225
|
+
**Diagnostics that fail the run:** `operation-operationId-unique`,
|
|
226
|
+
`openapi-tags-uniqueness`, and `invalid-ref` for unresolved `$ref`s. Any other
|
|
227
|
+
diagnostic that Spectral reports with error severity also fails, except the two cases
|
|
228
|
+
listed above.
|
|
229
|
+
|
|
230
|
+
**Common warnings (non-blocking):** `oas3-api-servers`, `info-contact`,
|
|
231
|
+
`info-description`, `operation-description`, `operation-tags`, `operation-tag-defined`,
|
|
232
|
+
`operation-success-response`, `oas3-unused-component`, `duplicated-entry-in-enum`,
|
|
233
|
+
`path-params`, and the other recommended `spectral:oas` rules.
|
|
234
|
+
|
|
235
|
+
### Strict mode
|
|
236
|
+
|
|
237
|
+
Pass `strict: true` to turn off the lenient overrides:
|
|
238
|
+
|
|
239
|
+
- Every `spectral:oas` rule keeps its original severity. Rules that default to `error`
|
|
240
|
+
(`path-params`, `array-items`, `no-$ref-siblings`, and the others listed above) fail
|
|
241
|
+
the run.
|
|
242
|
+
- `parser` diagnostics (YAML or JSON syntax errors, such as duplicate keys) fail the run.
|
|
243
|
+
- `oas3-schema` stays **off**, and `invalid-ref` diagnostics for `$ref`s inside
|
|
244
|
+
`examples` are still warnings.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
yield * linter.lintDocument(spec, { sourceUrl: "file:///openapi.yaml", strict: true });
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Apart from `strict`, you cannot change the rules. The package does not support a `.spectral.yaml` file,
|
|
251
|
+
custom rulesets, or per-rule overrides.
|
|
252
|
+
|
|
253
|
+
## Supported OpenAPI versions
|
|
254
|
+
|
|
255
|
+
- **Supported:** OpenAPI 3.0.x, 3.1.x, and 3.2.x. The check is
|
|
256
|
+
`parseInt(document.openapi) === 3`.
|
|
257
|
+
- **Rejected:** Swagger / OpenAPI 2.0 and any non-object input. Both fail with code
|
|
258
|
+
`unsupported-format`.
|
|
259
|
+
|
|
260
|
+
## Caveats
|
|
261
|
+
|
|
262
|
+
- **The linter does not validate the document's structure.** Because `oas3-schema` is
|
|
263
|
+
off, a document with no `paths` or a wrongly typed `info` still passes. Spectral only
|
|
264
|
+
prints warnings for those problems.
|
|
265
|
+
- **YAML syntax errors do not fail by default.** They appear only as `⚠` warnings.
|
|
266
|
+
Pass `strict: true` to make them fail the run.
|
|
267
|
+
- **JSON detection relies on the first character.** A string is treated as JSON only
|
|
268
|
+
if its first non-whitespace character is `{`. Every other string is parsed as YAML.
|
|
269
|
+
JSON is a subset of YAML, so most JSON still lints correctly through the YAML path.
|
|
270
|
+
- **The document is linted as given.** The linter does not bundle or normalize it.
|
|
271
|
+
Diagnostics point at paths in your original document.
|
|
272
|
+
- **The linter writes to the console.** `silent: true` hides warnings only. To
|
|
273
|
+
suppress everything, provide a no-op `Console` service.
|
|
274
|
+
|
|
275
|
+
## Troubleshooting
|
|
276
|
+
|
|
277
|
+
- **`Expected an OpenAPI 3.x document`**: the root object has no `openapi: 3.x` field.
|
|
278
|
+
This also happens with a Swagger 2.0 file or an empty object.
|
|
279
|
+
- **The run fails but you only see warnings above the error**: only the `✘` lines
|
|
280
|
+
are blocking. Read `error.diagnostics` for the exact set.
|
|
281
|
+
- **Unexpected `invalid-ref` errors**: check that every local `$ref` target exists
|
|
282
|
+
under `#/components/...`. When you lint a file or URL, pass its location as
|
|
283
|
+
`sourceUrl`, as the `openapi lint` CLI does.
|
|
284
|
+
- **Escape codes in logs**: set `NO_COLOR=1`, or run without a TTY.
|
|
285
|
+
|
|
286
|
+
## Key facts (for AI agents)
|
|
287
|
+
|
|
288
|
+
- Exports: `Linter` (an Effect `Context.Service` with `Linter.layer`) and `LintError`
|
|
289
|
+
(an Effect `Schema.TaggedError`, `_tag: "LintError"`). There are no other exports.
|
|
290
|
+
- Call pattern:
|
|
291
|
+
`Linter.use((l) => l.lintDocument(source, { sourceUrl?, silent?, strict? })).pipe(Effect.provide(Linter.layer))`.
|
|
292
|
+
- `source` can be a JSON string, a YAML string, or a plain object. The call returns
|
|
293
|
+
`Effect<void, LintError>`.
|
|
294
|
+
- Inputs that are not OpenAPI 3.x fail with diagnostic code `unsupported-format`.
|
|
295
|
+
- The ruleset is `spectral:oas` (recommended) with `oas3-schema` off. Error-level oas
|
|
296
|
+
rules are downgraded to warnings, except `operation-operationId-unique`.
|
|
297
|
+
`openapi-tags-uniqueness` and `invalid-ref` also fail. Parser errors only warn.
|
|
298
|
+
- `strict: true` keeps Spectral's original `oas` severities and makes parser
|
|
299
|
+
(syntax) diagnostics fail. `oas3-schema` stays off in both modes. Nothing else is
|
|
300
|
+
configurable.
|
|
301
|
+
- Output goes through Effect `Console` (`error` for blocking diagnostics, `warn`
|
|
302
|
+
otherwise). Color is used only on a TTY without `NO_COLOR`.
|
|
303
|
+
- Requirements: ESM only, compiled JavaScript and `.d.ts` declarations in `dist/`,
|
|
304
|
+
Node.js `>=22.19.0` or Bun, and a dependency on `effect@^4.0.0`.
|
|
305
|
+
- The CLI equivalent is `openapi lint [--strict] <file-or-url>` from
|
|
306
|
+
`@shipworthy/openapi-cli`.
|
|
307
|
+
|
|
308
|
+
## License
|
|
309
|
+
|
|
310
|
+
MIT. See [LICENSE](./LICENSE).
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export declare const Diagnostic: Schema.Struct<{
|
|
3
|
+
readonly code: Schema.Union<readonly [Schema.String, Schema.Finite]>;
|
|
4
|
+
readonly message: Schema.String;
|
|
5
|
+
readonly path: Schema.$Array<Schema.Union<readonly [Schema.String, Schema.Finite]>>;
|
|
6
|
+
}>;
|
|
7
|
+
declare const LintError_base: Schema.Class<LintError, Schema.TaggedStruct<"LintError", {
|
|
8
|
+
readonly message: Schema.String;
|
|
9
|
+
readonly diagnostics: Schema.$Array<Schema.Struct<{
|
|
10
|
+
readonly code: Schema.Union<readonly [Schema.String, Schema.Finite]>;
|
|
11
|
+
readonly message: Schema.String;
|
|
12
|
+
readonly path: Schema.$Array<Schema.Union<readonly [Schema.String, Schema.Finite]>>;
|
|
13
|
+
}>>;
|
|
14
|
+
readonly cause: Schema.optional<Schema.Defect>;
|
|
15
|
+
}>, import("effect/Cause").YieldableError>;
|
|
16
|
+
export declare class LintError extends LintError_base {
|
|
17
|
+
}
|
|
18
|
+
export {};
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export const Diagnostic = Schema.Struct({
|
|
3
|
+
code: Schema.Union([Schema.String, Schema.Finite]),
|
|
4
|
+
message: Schema.String,
|
|
5
|
+
path: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
|
|
6
|
+
});
|
|
7
|
+
export class LintError extends Schema.TaggedError()("LintError", {
|
|
8
|
+
message: Schema.String,
|
|
9
|
+
diagnostics: Schema.Array(Diagnostic),
|
|
10
|
+
cause: Schema.optional(Schema.Defect()),
|
|
11
|
+
}) {
|
|
12
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/lint.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { LintError } from "./errors.ts";
|
|
3
|
+
interface LintOptions {
|
|
4
|
+
readonly sourceUrl?: string;
|
|
5
|
+
readonly silent?: boolean;
|
|
6
|
+
/** Keep Spectral's `oas` severities and fail on YAML/JSON syntax errors. */
|
|
7
|
+
readonly strict?: boolean;
|
|
8
|
+
}
|
|
9
|
+
declare const Linter_base: Context.ServiceClass<Linter, "@shipworthy/openapi-validator/Linter", {
|
|
10
|
+
readonly lintDocument: (source: string | Record<string, unknown>, options?: LintOptions) => Effect.Effect<void, LintError>;
|
|
11
|
+
}>;
|
|
12
|
+
export declare class Linter extends Linter_base {
|
|
13
|
+
static readonly layer: Layer.Layer<Linter, never, never>;
|
|
14
|
+
}
|
|
15
|
+
export {};
|
package/dist/lint.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Document, Spectral } from "@stoplight/spectral-core";
|
|
2
|
+
import { oas3 } from "@stoplight/spectral-formats";
|
|
3
|
+
// spectral-parsers is CommonJS without statically detectable named exports, so Node needs a default import.
|
|
4
|
+
import spectralParsers from "@stoplight/spectral-parsers";
|
|
5
|
+
import { oas } from "@stoplight/spectral-rulesets";
|
|
6
|
+
import { Array as EffectArray, Config, Console, Context, Effect, Layer, Predicate } from "effect";
|
|
7
|
+
import { LintError } from "./errors.js";
|
|
8
|
+
/** Same TTY and `NO_COLOR` check as `CliOutput.defaultFormatter`. */
|
|
9
|
+
const colorsEnabled = Effect.gen(function* () {
|
|
10
|
+
const noColor = yield* Config.String("NO_COLOR").pipe(Config.withDefault(""), Effect.orDie);
|
|
11
|
+
const current = globalThis.process;
|
|
12
|
+
if (typeof current !== "object" || current === null)
|
|
13
|
+
return false;
|
|
14
|
+
const stdout = current.stdout;
|
|
15
|
+
if (typeof stdout !== "object" || stdout === null)
|
|
16
|
+
return false;
|
|
17
|
+
return stdout.isTTY === true && !noColor;
|
|
18
|
+
});
|
|
19
|
+
function paint(code, enabled) {
|
|
20
|
+
return (text) => (enabled ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
21
|
+
}
|
|
22
|
+
function spectralRules(strict) {
|
|
23
|
+
const rules = {
|
|
24
|
+
// Lint can inspect partial documents; the parser owns structural validation.
|
|
25
|
+
"oas3-schema": "off",
|
|
26
|
+
};
|
|
27
|
+
if (strict)
|
|
28
|
+
return rules;
|
|
29
|
+
for (const [id, rule] of Object.entries(oas.rules ?? {})) {
|
|
30
|
+
if (id === "operation-operationId-unique")
|
|
31
|
+
continue;
|
|
32
|
+
if (Predicate.isObject(rule) && "severity" in rule && rule.severity === 0) {
|
|
33
|
+
rules[id] = "warn";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return rules;
|
|
37
|
+
}
|
|
38
|
+
function isExampleRefRead(result) {
|
|
39
|
+
return (result.code === "invalid-ref" &&
|
|
40
|
+
result.message.startsWith("ReadError:") &&
|
|
41
|
+
result.path.includes("examples"));
|
|
42
|
+
}
|
|
43
|
+
function makeSpectral(strict) {
|
|
44
|
+
const spectral = new Spectral();
|
|
45
|
+
spectral.setRuleset({
|
|
46
|
+
extends: [oas],
|
|
47
|
+
formats: [oas3],
|
|
48
|
+
parserOptions: {
|
|
49
|
+
// OpenAPI status codes are YAML numbers (`200:`). Spectral's parser can flag that.
|
|
50
|
+
incompatibleValues: "off",
|
|
51
|
+
},
|
|
52
|
+
rules: spectralRules(strict),
|
|
53
|
+
});
|
|
54
|
+
return spectral;
|
|
55
|
+
}
|
|
56
|
+
function spectralTarget(source, sourceUrl) {
|
|
57
|
+
if (typeof source !== "string")
|
|
58
|
+
return source;
|
|
59
|
+
if (source.trimStart().startsWith("{")) {
|
|
60
|
+
return new Document(source, spectralParsers.Json, sourceUrl);
|
|
61
|
+
}
|
|
62
|
+
return new Document(source, spectralParsers.Yaml, sourceUrl);
|
|
63
|
+
}
|
|
64
|
+
function thrownMessage(cause) {
|
|
65
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
66
|
+
}
|
|
67
|
+
function pathSegments(path) {
|
|
68
|
+
return path.filter((segment) => Predicate.isString(segment) || Predicate.isNumber(segment));
|
|
69
|
+
}
|
|
70
|
+
export class Linter extends Context.Service()("@shipworthy/openapi-validator/Linter") {
|
|
71
|
+
static layer = Layer.sync(Linter, () => {
|
|
72
|
+
const lenient = makeSpectral(false);
|
|
73
|
+
const strict = makeSpectral(true);
|
|
74
|
+
const lintDocument = Effect.fn("Linter.lintDocument")(function* (source, options = {}) {
|
|
75
|
+
const isStrict = options.strict === true;
|
|
76
|
+
const spectral = isStrict ? strict : lenient;
|
|
77
|
+
const target = yield* Effect.try({
|
|
78
|
+
try: () => spectralTarget(source, options.sourceUrl),
|
|
79
|
+
catch: (cause) => new LintError({ message: thrownMessage(cause), diagnostics: [], cause }),
|
|
80
|
+
});
|
|
81
|
+
if (!oas3(target instanceof Document ? target.data : target, options.sourceUrl ?? null)) {
|
|
82
|
+
const message = "Expected an OpenAPI 3.x document";
|
|
83
|
+
return yield* new LintError({
|
|
84
|
+
message,
|
|
85
|
+
diagnostics: [{ code: "unsupported-format", message, path: [] }],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const results = yield* Effect.tryPromise({
|
|
89
|
+
try: () => spectral.run(target),
|
|
90
|
+
catch: (cause) => new LintError({
|
|
91
|
+
message: thrownMessage(cause),
|
|
92
|
+
diagnostics: [],
|
|
93
|
+
cause,
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
const useColors = yield* colorsEnabled;
|
|
97
|
+
const red = paint("31", useColors);
|
|
98
|
+
const yellow = paint("33", useColors);
|
|
99
|
+
const failures = yield* Effect.forEach(results, Effect.fnUntraced(function* (result) {
|
|
100
|
+
const location = result.path.length ? ` at #/${result.path.join("/")}` : "";
|
|
101
|
+
const message = `${result.message}${location}`;
|
|
102
|
+
// Spectral's parser rejects merge tags this repo accepts, and example payloads
|
|
103
|
+
// use `$ref` as data. Those diagnostics are not document errors unless strict.
|
|
104
|
+
const blocking = result.severity === 0 &&
|
|
105
|
+
(isStrict || result.code !== "parser") &&
|
|
106
|
+
!isExampleRefRead(result);
|
|
107
|
+
if (blocking) {
|
|
108
|
+
yield* Console.error(red(` ✘ ${message}`));
|
|
109
|
+
return { code: result.code, message, path: pathSegments(result.path) };
|
|
110
|
+
}
|
|
111
|
+
else if (!options.silent) {
|
|
112
|
+
yield* Console.warn(yellow(` ⚠ ${message}`));
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
})).pipe(Effect.map(EffectArray.filter(Predicate.isNotUndefined)));
|
|
116
|
+
if (failures.length > 0) {
|
|
117
|
+
return yield* new LintError({
|
|
118
|
+
message: failures.map((diagnostic) => diagnostic.message).join("\n"),
|
|
119
|
+
diagnostics: failures,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
return Linter.of({ lintDocument });
|
|
124
|
+
});
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipworthy/openapi-validator",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"files": [
|
|
5
|
+
"dist"
|
|
6
|
+
],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@stoplight/spectral-core": "1.24.0",
|
|
9
|
+
"@stoplight/spectral-formats": "1.9.0",
|
|
10
|
+
"@stoplight/spectral-parsers": "1.0.5",
|
|
11
|
+
"@stoplight/spectral-rulesets": "1.23.0",
|
|
12
|
+
"effect": "^4.0.0"
|
|
13
|
+
},
|
|
14
|
+
"description": "Lint an OpenAPI document.",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/ShipWorthyAI/openapi.git",
|
|
19
|
+
"directory": "packages/openapi-validator"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@effect/vitest": "4.0.0",
|
|
35
|
+
"@shipworthy/typescript-config": "0.0.1",
|
|
36
|
+
"@types/node": "^24.10.3",
|
|
37
|
+
"typescript": "^7.0.2",
|
|
38
|
+
"vitest": "5.0.3"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.19.0"
|
|
42
|
+
},
|
|
43
|
+
"main": "./dist/index.js",
|
|
44
|
+
"types": "./dist/index.d.ts",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|