@atproto/oauth-scopes 0.0.1
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/CHANGELOG.md +7 -0
- package/LICENSE.txt +7 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/did.d.ts +3 -0
- package/dist/lib/did.d.ts.map +1 -0
- package/dist/lib/did.js +6 -0
- package/dist/lib/did.js.map +1 -0
- package/dist/lib/mime.d.ts +7 -0
- package/dist/lib/mime.d.ts.map +1 -0
- package/dist/lib/mime.js +65 -0
- package/dist/lib/mime.js.map +1 -0
- package/dist/lib/nsid.d.ts +3 -0
- package/dist/lib/nsid.d.ts.map +1 -0
- package/dist/lib/nsid.js +9 -0
- package/dist/lib/nsid.js.map +1 -0
- package/dist/lib/util.d.ts +3 -0
- package/dist/lib/util.d.ts.map +1 -0
- package/dist/lib/util.js +24 -0
- package/dist/lib/util.js.map +1 -0
- package/dist/parser.d.ts +31 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +118 -0
- package/dist/parser.js.map +1 -0
- package/dist/permission-set-transition.d.ts +15 -0
- package/dist/permission-set-transition.d.ts.map +1 -0
- package/dist/permission-set-transition.js +52 -0
- package/dist/permission-set-transition.js.map +1 -0
- package/dist/permission-set.d.ts +22 -0
- package/dist/permission-set.d.ts.map +1 -0
- package/dist/permission-set.js +68 -0
- package/dist/permission-set.js.map +1 -0
- package/dist/resources/account-scope.d.ts +35 -0
- package/dist/resources/account-scope.d.ts.map +1 -0
- package/dist/resources/account-scope.js +60 -0
- package/dist/resources/account-scope.js.map +1 -0
- package/dist/resources/blob-scope.d.ts +25 -0
- package/dist/resources/blob-scope.d.ts.map +1 -0
- package/dist/resources/blob-scope.js +74 -0
- package/dist/resources/blob-scope.js.map +1 -0
- package/dist/resources/identity-scope.d.ts +25 -0
- package/dist/resources/identity-scope.d.ts.map +1 -0
- package/dist/resources/identity-scope.js +46 -0
- package/dist/resources/identity-scope.js.map +1 -0
- package/dist/resources/repo-scope.d.ts +37 -0
- package/dist/resources/repo-scope.d.ts.map +1 -0
- package/dist/resources/repo-scope.js +92 -0
- package/dist/resources/repo-scope.js.map +1 -0
- package/dist/resources/rpc-scope.d.ts +31 -0
- package/dist/resources/rpc-scope.d.ts.map +1 -0
- package/dist/resources/rpc-scope.js +74 -0
- package/dist/resources/rpc-scope.js.map +1 -0
- package/dist/scope-missing-error.d.ts +9 -0
- package/dist/scope-missing-error.d.ts.map +1 -0
- package/dist/scope-missing-error.js +39 -0
- package/dist/scope-missing-error.js.map +1 -0
- package/dist/scopes-set.d.ts +21 -0
- package/dist/scopes-set.d.ts.map +1 -0
- package/dist/scopes-set.js +55 -0
- package/dist/scopes-set.js.map +1 -0
- package/dist/syntax.d.ts +76 -0
- package/dist/syntax.d.ts.map +1 -0
- package/dist/syntax.js +249 -0
- package/dist/syntax.js.map +1 -0
- package/dist/utilities.d.ts +17 -0
- package/dist/utilities.d.ts.map +1 -0
- package/dist/utilities.js +108 -0
- package/dist/utilities.js.map +1 -0
- package/jest.config.js +5 -0
- package/package.json +36 -0
- package/src/index.ts +17 -0
- package/src/lib/did.ts +3 -0
- package/src/lib/mime.test.ts +98 -0
- package/src/lib/mime.ts +70 -0
- package/src/lib/nsid.ts +6 -0
- package/src/lib/util.ts +19 -0
- package/src/parser.ts +150 -0
- package/src/permission-set-transition.test.ts +109 -0
- package/src/permission-set-transition.ts +67 -0
- package/src/permission-set.test.ts +225 -0
- package/src/permission-set.ts +78 -0
- package/src/resources/account-scope.test.ts +175 -0
- package/src/resources/account-scope.ts +66 -0
- package/src/resources/blob-scope.test.ts +118 -0
- package/src/resources/blob-scope.ts +86 -0
- package/src/resources/identity-scope.test.ts +80 -0
- package/src/resources/identity-scope.ts +49 -0
- package/src/resources/repo-scope.test.ts +255 -0
- package/src/resources/repo-scope.ts +101 -0
- package/src/resources/rpc-scope.test.ts +280 -0
- package/src/resources/rpc-scope.ts +77 -0
- package/src/scope-missing-error.ts +15 -0
- package/src/scopes-set.test.ts +47 -0
- package/src/scopes-set.ts +60 -0
- package/src/syntax.test.ts +203 -0
- package/src/syntax.ts +325 -0
- package/src/utilities.ts +109 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +7 -0
- package/tsconfig.tests.json +7 -0
- package/tsconfig.tests.tsbuildinfo +1 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { DIDLike, isDIDLike } from '../lib/did.js'
|
|
2
|
+
import { NSID, isNSID } from '../lib/nsid.js'
|
|
3
|
+
import { Parser } from '../parser.js'
|
|
4
|
+
import { NeRoArray, ResourceSyntax, isScopeForResource } from '../syntax.js'
|
|
5
|
+
|
|
6
|
+
const validateLxmParam = (value: string) => value === '*' || isNSID(value)
|
|
7
|
+
const validateAudParam = (value: string) => value === '*' || isDIDLike(value)
|
|
8
|
+
|
|
9
|
+
export const rpcParser = new Parser(
|
|
10
|
+
'rpc',
|
|
11
|
+
{
|
|
12
|
+
lxm: {
|
|
13
|
+
multiple: true,
|
|
14
|
+
required: true,
|
|
15
|
+
validate: validateLxmParam,
|
|
16
|
+
},
|
|
17
|
+
aud: {
|
|
18
|
+
multiple: false,
|
|
19
|
+
required: true,
|
|
20
|
+
validate: validateAudParam,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
'lxm',
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
export type RpcScopeMatch = {
|
|
27
|
+
lxm: string
|
|
28
|
+
aud: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class RpcScope {
|
|
32
|
+
constructor(
|
|
33
|
+
public readonly aud: '*' | DIDLike,
|
|
34
|
+
public readonly lxm: NeRoArray<'*' | NSID>,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
matches(options: RpcScopeMatch): boolean {
|
|
38
|
+
const { aud, lxm } = this
|
|
39
|
+
return (
|
|
40
|
+
(aud === '*' || aud === options.aud) &&
|
|
41
|
+
(lxm.includes('*') || (lxm as readonly string[]).includes(options.lxm))
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
toString(): string {
|
|
46
|
+
const { lxm, aud } = this
|
|
47
|
+
return rpcParser.format({
|
|
48
|
+
aud,
|
|
49
|
+
lxm: lxm.includes('*')
|
|
50
|
+
? ['*']
|
|
51
|
+
: ([...new Set(lxm)].sort() as [NSID, ...NSID[]]),
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static fromString(scope: string): RpcScope | null {
|
|
56
|
+
if (!isScopeForResource(scope, 'rpc')) return null
|
|
57
|
+
const syntax = ResourceSyntax.fromString(scope)
|
|
58
|
+
return this.fromSyntax(syntax)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static fromSyntax(syntax: ResourceSyntax): RpcScope | null {
|
|
62
|
+
const result = rpcParser.parse(syntax)
|
|
63
|
+
if (!result) return null
|
|
64
|
+
|
|
65
|
+
// rpc:*?aud=* is forbidden
|
|
66
|
+
if (result.aud === '*' && result.lxm.includes('*')) return null
|
|
67
|
+
|
|
68
|
+
return new RpcScope(result.aud, result.lxm)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static scopeNeededFor(options: RpcScopeMatch): string {
|
|
72
|
+
return rpcParser.format({
|
|
73
|
+
aud: options.aud as DIDLike,
|
|
74
|
+
lxm: [options.lxm as NSID],
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class ScopeMissingError extends Error {
|
|
2
|
+
name = 'ScopeMissingError'
|
|
3
|
+
|
|
4
|
+
// compatibility layer with http-errors package. The goal if to make
|
|
5
|
+
// isHttpError(new ScopeMissingError) return true.
|
|
6
|
+
status = 403
|
|
7
|
+
expose = true
|
|
8
|
+
get statusCode() {
|
|
9
|
+
return this.status
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
constructor(public readonly scope: string) {
|
|
13
|
+
super(`Missing required scope "${scope}"`)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ScopesSet } from './scopes-set.js'
|
|
2
|
+
|
|
3
|
+
describe('ScopesSet', () => {
|
|
4
|
+
it('should initialize with an empty set', () => {
|
|
5
|
+
const set = new ScopesSet()
|
|
6
|
+
expect(set.size).toBe(0)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('should add scopes correctly', () => {
|
|
10
|
+
const set = new ScopesSet()
|
|
11
|
+
set.add('repo:read')
|
|
12
|
+
expect(set.size).toBe(1)
|
|
13
|
+
expect(set.has('repo:read')).toBe(true)
|
|
14
|
+
expect(set.has('repo:write')).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should remove scopes correctly', () => {
|
|
18
|
+
const set = new ScopesSet(['repo:read'])
|
|
19
|
+
set.delete('repo:read')
|
|
20
|
+
expect(set.size).toBe(0)
|
|
21
|
+
expect(set.has('repo:read')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should match included scopes', () => {
|
|
25
|
+
const set = new ScopesSet(['repo:foo.bar'])
|
|
26
|
+
expect(
|
|
27
|
+
set.matches('repo', { action: 'create', collection: 'foo.bar' }),
|
|
28
|
+
).toBe(true)
|
|
29
|
+
expect(
|
|
30
|
+
set.matches('repo', { action: 'create', collection: 'baz.qux' }),
|
|
31
|
+
).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should not match missing scopes', () => {
|
|
35
|
+
const set = new ScopesSet(['repo:foo.bar?action=create'])
|
|
36
|
+
expect(
|
|
37
|
+
set.matches('repo', { action: 'delete', collection: 'foo.bar' }),
|
|
38
|
+
).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should not match invalid scopes', () => {
|
|
42
|
+
const set = new ScopesSet(['repo:not-a-valid-nsid'])
|
|
43
|
+
expect(
|
|
44
|
+
set.matches('repo', { action: 'create', collection: 'not-a-valid-nsid' }),
|
|
45
|
+
).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ScopeMissingError } from './scope-missing-error.js'
|
|
2
|
+
import {
|
|
3
|
+
ScopeMatchingOptionsByResource,
|
|
4
|
+
scopeMatches,
|
|
5
|
+
scopeNeededFor,
|
|
6
|
+
} from './utilities.js'
|
|
7
|
+
|
|
8
|
+
export { ScopeMissingError }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Utility class to manage a set of scopes and check if they match specific
|
|
12
|
+
* options for a given resource.
|
|
13
|
+
*/
|
|
14
|
+
export class ScopesSet extends Set<string> {
|
|
15
|
+
/**
|
|
16
|
+
* Check if the container has a scope that matches the given options for a
|
|
17
|
+
* specific resource.
|
|
18
|
+
*/
|
|
19
|
+
public matches<R extends keyof ScopeMatchingOptionsByResource>(
|
|
20
|
+
resource: R,
|
|
21
|
+
options: ScopeMatchingOptionsByResource[R],
|
|
22
|
+
): boolean {
|
|
23
|
+
for (const scope of this) {
|
|
24
|
+
if (scopeMatches(scope, resource, options)) return true
|
|
25
|
+
}
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public assert<R extends keyof ScopeMatchingOptionsByResource>(
|
|
30
|
+
resource: R,
|
|
31
|
+
options: ScopeMatchingOptionsByResource[R],
|
|
32
|
+
) {
|
|
33
|
+
if (!this.matches(resource, options)) {
|
|
34
|
+
const scope = scopeNeededFor(resource, options)
|
|
35
|
+
throw new ScopeMissingError(scope)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public some(fn: (scope: string) => boolean): boolean {
|
|
40
|
+
for (const scope of this) if (fn(scope)) return true
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public every(fn: (scope: string) => boolean): boolean {
|
|
45
|
+
for (const scope of this) if (!fn(scope)) return false
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public *filter(fn: (scope: string) => boolean) {
|
|
50
|
+
for (const scope of this) if (fn(scope)) yield scope
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public *map<O>(fn: (scope: string) => O) {
|
|
54
|
+
for (const scope of this) yield fn(scope)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static fromString(string?: string): ScopesSet {
|
|
58
|
+
return new ScopesSet(string?.split(' '))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { ResourceSyntax, isScopeForResource } from './syntax.js'
|
|
2
|
+
|
|
3
|
+
describe('isScopeForResource', () => {
|
|
4
|
+
describe('exact match', () => {
|
|
5
|
+
it('should return true for exact match', () => {
|
|
6
|
+
expect(isScopeForResource('resource', 'resource')).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('should return false for different resource', () => {
|
|
10
|
+
expect(isScopeForResource('resource', 'differentResource')).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('with positional parameter', () => {
|
|
15
|
+
it('should return true for exact match with positional parameter', () => {
|
|
16
|
+
expect(isScopeForResource('resource:positional', 'resource')).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should return false for different resource with positional parameter', () => {
|
|
20
|
+
expect(
|
|
21
|
+
isScopeForResource('differentResource:positional', 'resource'),
|
|
22
|
+
).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('with named parameters', () => {
|
|
27
|
+
it('should return true for exact match with named parameters', () => {
|
|
28
|
+
expect(isScopeForResource('resource?param=value', 'resource')).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should return false for different resource with named parameters', () => {
|
|
32
|
+
expect(
|
|
33
|
+
isScopeForResource('differentResource?param=value', 'resource'),
|
|
34
|
+
).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
for (const { scope, normalized = scope, content } of [
|
|
40
|
+
{
|
|
41
|
+
scope: 'my-res',
|
|
42
|
+
content: { resource: 'my-res' },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
scope: 'my-res:my-pos',
|
|
46
|
+
content: { resource: 'my-res', positional: 'my-pos' },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
scope: 'my-res:',
|
|
50
|
+
content: { resource: 'my-res', positional: '' },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
scope: 'my-res:foo?x=value&y=value-y',
|
|
54
|
+
content: {
|
|
55
|
+
resource: 'my-res',
|
|
56
|
+
positional: 'foo',
|
|
57
|
+
params: { x: ['value'], y: ['value-y'] },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
scope: 'my-res?x=value&y=value-y',
|
|
62
|
+
content: { resource: 'my-res', params: { x: ['value'], y: ['value-y'] } },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
scope: 'my-res?x=foo&x=bar&x=baz',
|
|
66
|
+
content: { resource: 'my-res', params: { x: ['foo', 'bar', 'baz'] } },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
scope: 'rpc:foo.bar?aud=did:foo:bar?lxm=bar.baz',
|
|
70
|
+
normalized: 'rpc:foo.bar?aud=did:foo:bar%3Flxm%3Dbar.baz',
|
|
71
|
+
content: {
|
|
72
|
+
resource: 'rpc',
|
|
73
|
+
positional: 'foo.bar',
|
|
74
|
+
params: { aud: ['did:foo:bar?lxm=bar.baz'] },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
] as Array<{
|
|
78
|
+
scope: string
|
|
79
|
+
normalized?: string
|
|
80
|
+
content: {
|
|
81
|
+
resource: string
|
|
82
|
+
positional?: string
|
|
83
|
+
params?: Record<string, string[]>
|
|
84
|
+
}
|
|
85
|
+
}>) {
|
|
86
|
+
describe(`Valid "${scope}"`, () => {
|
|
87
|
+
const syntax = ResourceSyntax.fromString(scope)
|
|
88
|
+
|
|
89
|
+
it('should match the expected syntax', () => {
|
|
90
|
+
expect(syntax).toEqual({
|
|
91
|
+
resource: content.resource,
|
|
92
|
+
positional: content.positional,
|
|
93
|
+
params: content.params
|
|
94
|
+
? new URLSearchParams(
|
|
95
|
+
Object.entries(content.params).flatMap(([k, v]) =>
|
|
96
|
+
v.map((val) => [k, val]),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
: undefined,
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it(`should stringify ${scope} correctly`, () => {
|
|
104
|
+
expect(syntax.toString()).toBe(normalized)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it(`should parse ${scope} correctly`, () => {
|
|
108
|
+
expect(syntax.toJSON()).toMatchObject(content)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it(`should match ${scope} resource`, () => {
|
|
112
|
+
expect(syntax.is(content.resource)).toBe(true)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it(`should return undefined for nonexistent single-value param`, () => {
|
|
116
|
+
expect(syntax.getSingle('nonexistent')).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it(`should return undefined for nonexistent multi-value param`, () => {
|
|
120
|
+
expect(syntax.getMulti('nonexistent')).toBeUndefined()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const { params } = content
|
|
124
|
+
if (params) {
|
|
125
|
+
it(`should allow detecting unknown params`, () => {
|
|
126
|
+
const allowedParams = Object.keys(params) as [string, ...string[]]
|
|
127
|
+
expect(syntax.containsParamsOtherThan(allowedParams)).toBe(false)
|
|
128
|
+
|
|
129
|
+
if (allowedParams.length > 1) {
|
|
130
|
+
const woFirst = allowedParams.slice(1) as [string, ...string[]]
|
|
131
|
+
expect(syntax.containsParamsOtherThan(woFirst)).toBe(true)
|
|
132
|
+
|
|
133
|
+
const woLast = allowedParams.slice(0, -1) as [string, ...string[]]
|
|
134
|
+
expect(syntax.containsParamsOtherThan(woLast)).toBe(true)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
for (const [key, values] of Object.entries(params)) {
|
|
139
|
+
it(`should get an array when reading "${key}"`, () => {
|
|
140
|
+
expect(syntax.getMulti(key)).toEqual(values)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
if (values.length === 1) {
|
|
144
|
+
it(`should allow retrieving single-value params`, () => {
|
|
145
|
+
expect(syntax.getSingle(key)).toEqual(values[0])
|
|
146
|
+
})
|
|
147
|
+
} else {
|
|
148
|
+
it(`should return null for multi-value params`, () => {
|
|
149
|
+
expect(syntax.getSingle(key)).toBeNull()
|
|
150
|
+
expect(syntax.getSingle(key, true)).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { positional } = content
|
|
157
|
+
if (positional !== undefined) {
|
|
158
|
+
it(`should return positional parameter`, () => {
|
|
159
|
+
expect(syntax.positional).toBe(positional)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it(`should return positional parameter when reading as single-value`, () => {
|
|
163
|
+
expect(syntax.getSingle('nonexistent', true)).toBe(positional)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it(`should return positional parameter when reading as multi-value`, () => {
|
|
167
|
+
expect(syntax.getMulti('nonexistent', true)).toEqual([positional])
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
describe('invalid positional parameters', () => {
|
|
174
|
+
it('should return null for positional parameters used together with named parameters', () => {
|
|
175
|
+
const syntax = ResourceSyntax.fromString('my-res:pos?x=value')
|
|
176
|
+
expect(syntax.getSingle('x', true)).toBeNull()
|
|
177
|
+
expect(syntax.getMulti('x', true)).toBeNull()
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('containsParamsOtherThan', () => {
|
|
182
|
+
it('should return true if there are additional params', () => {
|
|
183
|
+
const syntax = ResourceSyntax.fromString('my-res?x=value&y=value-y')
|
|
184
|
+
expect(syntax.containsParamsOtherThan(['x'])).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('url encoding', () => {
|
|
189
|
+
it('should handle URL encoding in positional parameters', () => {
|
|
190
|
+
const syntax = ResourceSyntax.fromString('my-res:my%20pos')
|
|
191
|
+
expect(syntax.positional).toBe('my pos')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should handle URL encoding in named parameters', () => {
|
|
195
|
+
const syntax = ResourceSyntax.fromString('my-res?x=my%20value')
|
|
196
|
+
expect(syntax.getSingle('x')).toBe('my value')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it(`should allow colon (:) in positional parameters`, () => {
|
|
200
|
+
const syntax = ResourceSyntax.fromString('my-res:my:pos')
|
|
201
|
+
expect(syntax.positional).toBe('my:pos')
|
|
202
|
+
})
|
|
203
|
+
})
|