Skip to content

Commit

Permalink
feat(matching): add isIdentical predicate (#157)
Browse files Browse the repository at this point in the history
The isIdentical predicate determines if two paths are
identical and thus invalid.

Refs #98
  • Loading branch information
char0n authored Dec 31, 2024
1 parent 8f3dfbb commit fccddde
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 4 deletions.
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
Each template expression in the path MUST correspond to a path parameter that is included in the [Path Item](https://spec.openapis.org/oas/v3.1.1.html#path-item-object) itself and/or in each of the Path Item's [Operations](https://spec.openapis.org/oas/v3.1.1.html#operation-object).
An exception is if the path item is empty, for example due to ACL constraints, matching path parameters are not required.

`openapi-path-templating` is a **parser**, **validator**, and **resolver** for OpenAPI Path Templating,
`openapi-path-templating` is a **parser**, **validator**, **resolver** and **matcher** for OpenAPI Path Templating,
which played a [foundational role](https://github.com/OAI/OpenAPI-Specification/pull/4244) in defining the official ANBF grammar for OpenAPI Path Templating.

It supports Path Templating defined in following OpenAPI specification versions:
Expand Down Expand Up @@ -47,6 +47,7 @@ It supports Path Templating defined in following OpenAPI specification versions:
- [Parsing](#parsing)
- [Validation](#validation)
- [Resolution](#resolution)
- [Matching](#matching)
- [Grammar](#grammar)
- [More about OpenAPI Path Templating](#more-about-openapi-path-templating)
- [License](#license)
Expand All @@ -64,7 +65,7 @@ You can install `openapi-path-templating` using `npm`:

### Usage

`openapi-path-templating` currently supports **parsing**, **validation** and **resolution**.
`openapi-path-templating` currently supports **parsing**, **validation**, **resolution** and **matching**.
Both parser and validator are based on a superset of [ABNF](https://www.rfc-editor.org/rfc/rfc5234) ([SABNF](https://github.com/ldthomas/apg-js2/blob/master/SABNF.md))
and use [apg-lite](https://github.com/ldthomas/apg-lite) parser generator.

Expand Down Expand Up @@ -213,6 +214,27 @@ resolve('/pets/{petId}', { petId: '/?#' }, {
}); // => "/pets//?#"
```

#### Matching

Path templating matching in OpenAPI prioritizes concrete paths over parameterized ones,
treats paths with identical structures but different parameter names as invalid,
and considers paths with overlapping patterns that could match the same request as potentially ambiguous.

##### Predicates

**isIdentical**

Determines whether two path templates are structurally identical, meaning they have the same sequence
of literals and template expressions, regardless of template expression names. In the OpenAPI context,
such identical paths are considered invalid due to potential conflicts in routing.

```js
import { isIdentical } from 'openapi-path-templating';

isIdentical('/pets/{petId}', '/pets/{name}'); // => true
isIdentical('/pets/{petId}', '/animals/{name}'); // => false
```

#### Grammar

New grammar instance can be created in following way:
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "openapi-path-templating",
"version": "2.0.2",
"description": "OpenAPI Path Templating parser, validator and resolver.",
"description": "OpenAPI Path Templating parser, validator, resolver and matcher.",
"main": "./cjs/index.cjs",
"types": "./types/index.d.ts",
"exports": {
Expand Down Expand Up @@ -41,7 +41,9 @@
"templating",
"parser",
"validator",
"resolver"
"resolver",
"matcher",
"matching"
],
"author": "Vladimír Gorej <[email protected]>",
"license": "Apache-2.0",
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { default as Grammar } from './path-templating.js';
export { default as test } from './test.js';
export { default as parse } from './parse/index.js';
export { default as resolve, encodePathComponent } from './resolve.js';
export { isIdentical } from './predicates.js';
30 changes: 30 additions & 0 deletions src/predicates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import parse from './parse/index.js';

export const isIdentical = (pathTemplate1, pathTemplate2) => {
if (typeof pathTemplate1 !== 'string') return false;
if (typeof pathTemplate2 !== 'string') return false;

const parseResult1 = parse(pathTemplate1);
const parseResult2 = parse(pathTemplate2);
const parts1 = [];
const parts2 = [];

if (!parseResult1.result.success) return false;
if (!parseResult2.result.success) return false;

parseResult1.ast.translate(parts1);
parseResult2.ast.translate(parts2);

if (parts1.length !== parts2.length) return false;

for (let i = 1; i < parts1.length; i++) {
const [type1, value1] = parts1[i];
const [type2, value2] = parts2[i];

if (type1 !== type2) return false;
if (type1 === 'template-expression' || type1 === 'template-expression-param-name') continue;
if (value1 !== value2) return false;
}

return true;
};
48 changes: 48 additions & 0 deletions test/predicates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { assert } from 'chai';

import { isIdentical } from '../src/predicates.js';

describe('predicates', function () {
context('isIdentical', function () {
specify('should consider path templates identical', function () {
assert.isTrue(isIdentical('/pets/{petId}', '/pets/{name}'));
assert.isTrue(isIdentical('/pets/{petId}/owners/{ownerId}', '/pets/{id}/owners/{id}'));
assert.isTrue(isIdentical('/pets/static/segment', '/pets/static/segment'));
assert.isTrue(isIdentical('/{entity}/{id}', '/{resource}/{key}'));
assert.isTrue(isIdentical('/pets/{petId}/', '/pets/{name}/'));
});

specify('should not consider path templates identical', function () {
assert.isFalse(isIdentical('/', '/{petId}'));
assert.isFalse(isIdentical('/pets/{petId}', '/animals/{name}'));
assert.isFalse(isIdentical('/pets/{petId}', '/pets/{petId}/owners/{ownerId}'));
assert.isFalse(isIdentical('/pets/static/segment', '/pets/different/segment'));
assert.isFalse(isIdentical('/pets/{petId}/', '/pets/{petId}'));
assert.isFalse(isIdentical('/pets/{petId}/owners/{id}', '/pets/{petId}/owners/static'));
});

specify('should return false on invalid path templates', function () {
assert.isFalse(isIdentical('/a', 'b'));
assert.isFalse(isIdentical('a', '/b'));
assert.isFalse(isIdentical('a', 'b'));
assert.isFalse(isIdentical('', '/'));
});

specify('should handle complex cases correctly', function () {
assert.isTrue(isIdentical('/pets/{petId}/owners/{ownerId}', '/pets/{id}/owners/{id}'));
assert.isTrue(isIdentical('/{entity}/{id}/{action}', '/{resource}/{key}/{operation}'));
assert.isFalse(isIdentical('/pets/{petId}/owners/{ownerId}', '/pets/{id}/{ownerId}'));
assert.isFalse(isIdentical('/pets/{petId}/{ownerId}', '/pets/{id}/{id}/actions'));
assert.isFalse(isIdentical('/pets/{petId}/{ownerId}', '/pets/{petId}/{ownerId}/actions'));
});

specify('should handle non-string inputs gracefully', function () {
assert.isFalse(isIdentical(123, 456));
assert.isFalse(isIdentical({}, {}));
assert.isFalse(isIdentical([], []));
assert.isFalse(isIdentical(1, 2));
assert.isFalse(isIdentical(null, '/b'));
assert.isFalse(isIdentical(undefined, undefined));
});
});
});
1 change: 1 addition & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ export function test(pathTemplate: string, options?: TestOptions): boolean;
export function resolve(pathTemplate: string, parameters: Parameters, options?: ResolveOptions): string;
export function encodePathComponent(parameterValue: string): string;
export function Grammar(): Grammar;
export function isIdentical(pathTemplate1: string, pathTemplate2: string): boolean;

0 comments on commit fccddde

Please sign in to comment.