specshield 3.2.7 → 3.2.9
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/README.md +8 -6
- package/package.json +2 -2
- package/src/core/classifyChanges.js +6 -0
- package/src/core/diffEngine.js +64 -0
- package/src/core/normalizeSpec.js +33 -6
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
**Commands — local & hosted compare**
|
|
18
18
|
- [Local Compare](#local-compare) · [Remote Compare](#remote-compare) · [Comparison History](#comparison-history) · [Share a Comparison](#share-a-comparison) · [GitHub App PR Checks](#github-app--pr-checks)
|
|
19
19
|
|
|
20
|
-
**
|
|
21
|
-
- [
|
|
20
|
+
**Contract Compatibility Testing — the `bdct` commands**
|
|
21
|
+
- [Overview & full command reference](#bi-directional-contract-testing-bdct)
|
|
22
22
|
- [`bdct capture from-har` — HAR → consumer contract](#bdct-capture-from-har--turn-real-traffic-into-a-consumer-contract)
|
|
23
23
|
- [Step 1 — record a HAR](#step-1--record-a-har-file) · [Step 2 — generate contract](#step-2--turn-the-har-into-a-consumer-contract) · [Step 3 — publish + gate](#step-3--publish--gate) · [Operational concerns](#operational-concerns-read-this-before-going-live)
|
|
24
24
|
- [`bdct verify-provider` — spec-vs-production conformance](#bdct-verify-provider--does-your-live-provider-actually-match-its-spec)
|
|
@@ -34,21 +34,23 @@
|
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
37
|
-
> **OpenAPI Diff · Breaking-Change Detection ·
|
|
37
|
+
> **Contract Compatibility Testing · OpenAPI Diff · Breaking-Change Detection · `can-i-deploy` Deploy Gate · Pact-File Ingest · Live-Traffic Capture · Spec-vs-Production Conformance · GitHub PR Checks**
|
|
38
38
|
|
|
39
39
|
---
|
|
40
40
|
|
|
41
41
|
## Never ship a breaking change to your API consumers
|
|
42
42
|
|
|
43
|
-
**SpecShield
|
|
43
|
+
**SpecShield is contract compatibility testing for APIs** — catch breaking changes before they reach your consumers, and gate every deploy with `can-i-deploy`. *(Contract compatibility testing is also known as bidirectional contract testing.)*
|
|
44
|
+
|
|
45
|
+
It's the one CLI that does four things to keep your API safe:
|
|
44
46
|
|
|
45
47
|
1. **Diff** two OpenAPI specs and fail CI on breaking changes.
|
|
46
|
-
2. **
|
|
48
|
+
2. **Contract compatibility testing** with `can-i-deploy` — block a deploy that would break a consumer.
|
|
47
49
|
3. **`bdct capture from-har`** — turn recorded traffic into an accurate consumer contract (no Pact DSL).
|
|
48
50
|
4. **`bdct verify-provider`** — prove the running provider actually matches its OpenAPI spec.
|
|
49
51
|
|
|
50
52
|
```
|
|
51
|
-
OpenAPI diff +
|
|
53
|
+
OpenAPI diff + contract compatibility checks + HAR → consumer contract + spec-vs-production conformance — in one CLI.
|
|
52
54
|
```
|
|
53
55
|
|
|
54
56
|
No broker. No Pact DSL. Language-agnostic. Works in 30 seconds. Local mode never uploads your specs.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specshield",
|
|
3
|
-
"version": "3.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.2.9",
|
|
4
|
+
"description": "Contract compatibility testing for APIs — catch breaking OpenAPI changes before they reach your consumers, with can-i-deploy deploy gating and GitHub PR checks. (a.k.a. bidirectional contract testing.)",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"specshield": "./bin/specshield.js"
|
|
@@ -21,6 +21,10 @@ const BREAKING_TYPES = new Set([
|
|
|
21
21
|
'REQUEST_TYPE_CHANGED',
|
|
22
22
|
'RESPONSE_TYPE_CHANGED',
|
|
23
23
|
'SCHEMA_REMOVED',
|
|
24
|
+
// Dropping a union variant, or changing the discriminator, breaks consumers
|
|
25
|
+
// that relied on the removed shape / the old discriminator.
|
|
26
|
+
'SCHEMA_VARIANT_REMOVED',
|
|
27
|
+
'SCHEMA_DISCRIMINATOR_CHANGED',
|
|
24
28
|
// Constraint tightening: previously-valid values become invalid → breaking.
|
|
25
29
|
'CONSTRAINT_TIGHTENED',
|
|
26
30
|
// Pattern changes are treated as breaking (semantic safety: we can't
|
|
@@ -36,6 +40,8 @@ const ADDITION_TYPES = new Set([
|
|
|
36
40
|
'RESPONSE_FIELD_ADDED',
|
|
37
41
|
'RESPONSE_ADDED',
|
|
38
42
|
'SCHEMA_ADDED',
|
|
43
|
+
// Adding a union variant is backwards-compatible.
|
|
44
|
+
'SCHEMA_VARIANT_ADDED',
|
|
39
45
|
]);
|
|
40
46
|
|
|
41
47
|
const MODIFICATION_TYPES = new Set([
|
package/src/core/diffEngine.js
CHANGED
|
@@ -323,6 +323,70 @@ function diffSchemaNode(path, method, fieldPrefix, base, target, diffs, isReques
|
|
|
323
323
|
});
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
|
+
|
|
327
|
+
// additionalProperties (free-form map): compare the value schema so a
|
|
328
|
+
// breaking change inside map values is caught. ".<*>" denotes "any key".
|
|
329
|
+
if (base.additionalProperties || target.additionalProperties) {
|
|
330
|
+
const apField = `${fieldPrefix}.<*>`;
|
|
331
|
+
if (base.additionalProperties && target.additionalProperties) {
|
|
332
|
+
diffSchemaNode(path, method, apField, base.additionalProperties, target.additionalProperties, diffs, isRequest);
|
|
333
|
+
} else if (base.additionalProperties && !target.additionalProperties) {
|
|
334
|
+
diffs.push({
|
|
335
|
+
type: isRequest ? 'REQUEST_FIELD_REMOVED' : 'RESPONSE_FIELD_REMOVED',
|
|
336
|
+
path,
|
|
337
|
+
method,
|
|
338
|
+
field: apField,
|
|
339
|
+
description: `Map value schema (additionalProperties) removed from "${fieldPrefix}" in ${method.toUpperCase()} ${path}`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Discriminator rename/removal — breaks consumers keyed on the old name.
|
|
345
|
+
if (base.discriminator && base.discriminator !== target.discriminator) {
|
|
346
|
+
diffs.push({
|
|
347
|
+
type: 'SCHEMA_DISCRIMINATOR_CHANGED',
|
|
348
|
+
path,
|
|
349
|
+
method,
|
|
350
|
+
field: `${fieldPrefix}.discriminator`,
|
|
351
|
+
oldValue: base.discriminator,
|
|
352
|
+
newValue: target.discriminator || null,
|
|
353
|
+
description: `Discriminator at "${fieldPrefix}" changed from "${base.discriminator}" to "${target.discriminator || '(none)'}" in ${method.toUpperCase()} ${path}`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// oneOf / anyOf variant comparison (positional; field-level changes inside a
|
|
358
|
+
// matched variant are caught by recursion). Mirrors the backend engine.
|
|
359
|
+
diffVariants(path, method, `${fieldPrefix}.oneOf`, base.oneOf, target.oneOf, diffs, isRequest);
|
|
360
|
+
diffVariants(path, method, `${fieldPrefix}.anyOf`, base.anyOf, target.anyOf, diffs, isRequest);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function diffVariants(path, method, fieldPrefix, baseVariants, targetVariants, diffs, isRequest) {
|
|
364
|
+
const b = Array.isArray(baseVariants) ? baseVariants : [];
|
|
365
|
+
const t = Array.isArray(targetVariants) ? targetVariants : [];
|
|
366
|
+
if (b.length === 0 && t.length === 0) return;
|
|
367
|
+
|
|
368
|
+
const matched = Math.min(b.length, t.length);
|
|
369
|
+
for (let i = 0; i < matched; i++) {
|
|
370
|
+
diffSchemaNode(path, method, `${fieldPrefix}[${i}]`, b[i], t[i], diffs, isRequest);
|
|
371
|
+
}
|
|
372
|
+
for (let i = t.length; i < b.length; i++) {
|
|
373
|
+
diffs.push({
|
|
374
|
+
type: 'SCHEMA_VARIANT_REMOVED',
|
|
375
|
+
path,
|
|
376
|
+
method,
|
|
377
|
+
field: `${fieldPrefix}[${i}]`,
|
|
378
|
+
description: `A schema variant was removed from "${fieldPrefix}" in ${method.toUpperCase()} ${path}`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
for (let i = b.length; i < t.length; i++) {
|
|
382
|
+
diffs.push({
|
|
383
|
+
type: 'SCHEMA_VARIANT_ADDED',
|
|
384
|
+
path,
|
|
385
|
+
method,
|
|
386
|
+
field: `${fieldPrefix}[${i}]`,
|
|
387
|
+
description: `A schema variant was added to "${fieldPrefix}" in ${method.toUpperCase()} ${path}`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
326
390
|
}
|
|
327
391
|
|
|
328
392
|
// ─── Constraints (min/max, length, pattern) ─────────────────────────────────
|
|
@@ -102,20 +102,30 @@ function resolveSchema(schema, schemas, depth = 0) {
|
|
|
102
102
|
return resolveSchema(resolved, schemas, depth + 1);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
//
|
|
105
|
+
// allOf = composition → merge into one combined object.
|
|
106
106
|
if (schema.allOf) {
|
|
107
107
|
return mergeSchemas(schema.allOf, schemas, depth);
|
|
108
108
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
// oneOf / anyOf are NOT merged — they're kept as separate variants below so
|
|
110
|
+
// the diff engine can compare variants individually (added/removed variants
|
|
111
|
+
// + per-variant field changes). This matches the backend's variant-aware
|
|
112
|
+
// model; merging would lose variant identity and over-mark `required`.
|
|
113
|
+
|
|
114
|
+
// OpenAPI 3.1 expresses nullability as a type array: `type: [x, "null"]`.
|
|
115
|
+
// Normalize that to a single base type + nullable flag so it compares cleanly
|
|
116
|
+
// against 3.0's `nullable: true` form and doesn't surface a bogus type change.
|
|
117
|
+
let resolvedType = schema.type;
|
|
118
|
+
let resolvedNullable = Boolean(schema.nullable);
|
|
119
|
+
if (Array.isArray(resolvedType)) {
|
|
120
|
+
if (resolvedType.includes('null')) resolvedNullable = true;
|
|
121
|
+
resolvedType = resolvedType.find((t) => t !== 'null') || 'object';
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
const node = {
|
|
115
|
-
type:
|
|
125
|
+
type: resolvedType || 'object',
|
|
116
126
|
format: schema.format || null,
|
|
117
127
|
enum: schema.enum || null,
|
|
118
|
-
nullable:
|
|
128
|
+
nullable: resolvedNullable,
|
|
119
129
|
required: Array.isArray(schema.required) ? schema.required : [],
|
|
120
130
|
properties: {},
|
|
121
131
|
items: null,
|
|
@@ -143,6 +153,23 @@ function resolveSchema(schema, schemas, depth = 0) {
|
|
|
143
153
|
node.items = resolveSchema(schema.items, schemas, depth + 1);
|
|
144
154
|
}
|
|
145
155
|
|
|
156
|
+
// additionalProperties as a schema = a free-form map/dictionary value type
|
|
157
|
+
// (e.g. Map<String, Metric>). Boolean true/false is not a value schema.
|
|
158
|
+
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
159
|
+
node.additionalProperties = resolveSchema(schema.additionalProperties, schemas, depth + 1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// oneOf / anyOf variants kept separate (variant-aware model).
|
|
163
|
+
if (Array.isArray(schema.oneOf)) {
|
|
164
|
+
node.oneOf = schema.oneOf.map((s) => resolveSchema(s, schemas, depth + 1));
|
|
165
|
+
}
|
|
166
|
+
if (Array.isArray(schema.anyOf)) {
|
|
167
|
+
node.anyOf = schema.anyOf.map((s) => resolveSchema(s, schemas, depth + 1));
|
|
168
|
+
}
|
|
169
|
+
if (schema.discriminator && schema.discriminator.propertyName) {
|
|
170
|
+
node.discriminator = schema.discriminator.propertyName;
|
|
171
|
+
}
|
|
172
|
+
|
|
146
173
|
return node;
|
|
147
174
|
}
|
|
148
175
|
|