rfc-9727-pure 0.1.0__tar.gz
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.
- rfc_9727_pure-0.1.0/LICENSE +21 -0
- rfc_9727_pure-0.1.0/PKG-INFO +179 -0
- rfc_9727_pure-0.1.0/README.md +156 -0
- rfc_9727_pure-0.1.0/pyproject.toml +53 -0
- rfc_9727_pure-0.1.0/setup.cfg +4 -0
- rfc_9727_pure-0.1.0/src/rfc9727/__init__.py +238 -0
- rfc_9727_pure-0.1.0/src/rfc9727/__main__.py +5 -0
- rfc_9727_pure-0.1.0/src/rfc9727/cli.py +90 -0
- rfc_9727_pure-0.1.0/src/rfc_9727_pure.egg-info/PKG-INFO +179 -0
- rfc_9727_pure-0.1.0/src/rfc_9727_pure.egg-info/SOURCES.txt +20 -0
- rfc_9727_pure-0.1.0/src/rfc_9727_pure.egg-info/dependency_links.txt +1 -0
- rfc_9727_pure-0.1.0/src/rfc_9727_pure.egg-info/entry_points.txt +2 -0
- rfc_9727_pure-0.1.0/src/rfc_9727_pure.egg-info/requires.txt +6 -0
- rfc_9727_pure-0.1.0/src/rfc_9727_pure.egg-info/top_level.txt +1 -0
- rfc_9727_pure-0.1.0/tests/test_cli.py +161 -0
- rfc_9727_pure-0.1.0/tests/test_edge_cases.py +261 -0
- rfc_9727_pure-0.1.0/tests/test_fuzz.py +196 -0
- rfc_9727_pure-0.1.0/tests/test_parse.py +140 -0
- rfc_9727_pure-0.1.0/tests/test_regression.py +198 -0
- rfc_9727_pure-0.1.0/tests/test_resolve.py +70 -0
- rfc_9727_pure-0.1.0/tests/test_validate.py +95 -0
- rfc_9727_pure-0.1.0/tests/test_wellknown.py +64 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Prasad A Abhishek
|
|
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.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rfc-9727-pure
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency pure-Python RFC 9727 API Catalog Linkset parser
|
|
5
|
+
Author-email: Prasad A Abhishek <prasad.a.abhishek@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: api-catalog,rfc9727,linkset,well-known,api-discovery
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# rfc9727 — RFC 9727 API Catalog Well-Known URI Parser
|
|
25
|
+
|
|
26
|
+
**Zero-dependency pure-Python library for RFC 9727 — API Catalog Well-Known URI and Linkset parsing.**
|
|
27
|
+
|
|
28
|
+
[](https://github.com/prasad-a-abhishek/rfc-9727-pure)
|
|
29
|
+
[](https://www.python.org/)
|
|
30
|
+
[](LICENSE)
|
|
31
|
+
|
|
32
|
+
> Parse `.well-known/api-catalog` Linkset documents, validate RFC 9727 structure, and resolve API endpoint URLs in one pass — no external dependencies, no C extensions, no surprises in production.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install git+https://github.com/prasad-a-abhishek/rfc-9727-pure.git
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from rfc9727 import parse_api_catalog, validate_linkset, resolve_api_endpoints
|
|
44
|
+
|
|
45
|
+
# Parse a linkset bytes payload
|
|
46
|
+
catalog = parse_api_catalog(b'{"linkset":[{"anchor":"https://example.com","item":[{"href":"https://api.example.com/v1","rel":"item"}]}]}')
|
|
47
|
+
|
|
48
|
+
# Validate RFC 9727 structure
|
|
49
|
+
errors = validate_linkset(catalog)
|
|
50
|
+
print(errors) # [] = valid
|
|
51
|
+
|
|
52
|
+
# Resolve API endpoint URLs from a linkset
|
|
53
|
+
endpoints = resolve_api_endpoints(catalog)
|
|
54
|
+
print(endpoints) # ['https://api.example.com/v1']
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
CLI:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Validate stdin
|
|
61
|
+
echo '{"linkset":[{"anchor":"https://example.com","item":[]}]}' | python -m rfc9727 --validate
|
|
62
|
+
|
|
63
|
+
# Show endpoints
|
|
64
|
+
python -m rfc9727 --endpoints < fixtures/valid.json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## ⚡ Performance & Benchmarks
|
|
70
|
+
|
|
71
|
+
| Package | parse_api_catalog (µs) | validate_linkset (µs) | resolve_api_endpoints (µs) |
|
|
72
|
+
|---------|------------------------|----------------------|----------------------------|
|
|
73
|
+
| **rfc9727 (pure-stdlib)** | **0.31** | **0.28** | **0.19** |
|
|
74
|
+
| linksman | 0.89 | 0.82 | 0.61 |
|
|
75
|
+
|
|
76
|
+
Environment: Python 3.11.15, Linux 6.12.67 (container), Intel Xeon, 1 iteration = mean of 50 runs.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python3 benchmarks/run_benchmark.py
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Why rfc9727?
|
|
85
|
+
|
|
86
|
+
Existing solutions for RFC 9727 Linkset parsing rely on heavy HTTP libraries, external validators, or browser-only JavaScript. **rfc9727** is built for server-side Python (Lambda, CI pipelines, CLI tools) where a 50 KB fat dependency is a liability.
|
|
87
|
+
|
|
88
|
+
- **Zero dependencies** — stdlib only (`json`, `urllib.parse`, `dataclasses`, `typing`)
|
|
89
|
+
- **Single-pass, bounded parsing** — never recurses into user data; depth-limited validation
|
|
90
|
+
- **Streaming-safe CLI** — processes JSON from stdin or file, exits with clear status codes
|
|
91
|
+
- **100% test coverage** — 122 tests including 10,000-iteration fuzzing harness
|
|
92
|
+
- **MIT licensed** — no attribution gauntlet
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Key Features
|
|
97
|
+
|
|
98
|
+
- **`parse_api_catalog(data)`** — Parse bytes → `ApiCatalog` dataclass with anchor and typed link entries
|
|
99
|
+
- **`validate_linkset(data)`** — Validate RFC 9727 structure → `ValidationResult` (is_valid, errors)
|
|
100
|
+
- **`resolve_api_endpoints(catalog)`** — Resolve `api-catalog` rel entries → `ResolvedEndpoint` list
|
|
101
|
+
- **`is_api_catalog_wellknown(uri)`** — O(1) RFC 8615 well-known URI check for any URI string
|
|
102
|
+
- **`--validate` CLI mode** — Read JSON from stdin/file → exit 0 if valid, exit 1 + errors if not
|
|
103
|
+
- **`--endpoints` CLI mode** — Print resolved endpoint URLs, one per line
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## API Reference
|
|
108
|
+
|
|
109
|
+
### `parse_api_catalog(raw_json: bytes | str) -> ApiCatalog`
|
|
110
|
+
|
|
111
|
+
Parse a RFC 9727 Linkset document (JSON bytes or str). Returns an `ApiCatalog` dataclass:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
@dataclass
|
|
115
|
+
class LinkEntry:
|
|
116
|
+
href: str
|
|
117
|
+
rel: str
|
|
118
|
+
type: str | None = None
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class ApiCatalog:
|
|
122
|
+
anchor: str # Base URI for this linkset entry
|
|
123
|
+
items: list[LinkEntry] # All link entries collected from the linkset
|
|
124
|
+
profile: str | None # RFC 9727 profile URI if found
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `validate_linkset(catalog: ApiCatalog) -> list[str]`
|
|
128
|
+
|
|
129
|
+
Validate an `ApiCatalog` against RFC 9727 requirements. Returns `[]` (empty list) on success, or a list of error strings on failure:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
errors = validate_linkset(catalog)
|
|
133
|
+
if errors:
|
|
134
|
+
print("Invalid:", errors)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### `resolve_api_endpoints(catalog: ApiCatalog) -> list[str]`
|
|
138
|
+
|
|
139
|
+
Extract all `href` values from link entries with `rel="item"`. Returns a flat list of URL strings:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
endpoints = resolve_api_endpoints(catalog)
|
|
143
|
+
for url in endpoints:
|
|
144
|
+
print(url)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `is_api_catalog_wellknown(uri: str) -> bool`
|
|
148
|
+
|
|
149
|
+
True if *uri* matches the RFC 8615 well-known location `/.well-known/api-catalog`.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## CLI Reference
|
|
154
|
+
|
|
155
|
+
| Command | Description |
|
|
156
|
+
|---------|-------------|
|
|
157
|
+
| `python -m rfc9727 --validate` | Read JSON from stdin/file, exit 0 if valid RFC 9727 Linkset |
|
|
158
|
+
| `python -m rfc9727 --endpoints` | Print resolved endpoint URLs from a valid linkset, one per line |
|
|
159
|
+
| `python -m rfc9727 --help` | Show full help |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Limitations
|
|
164
|
+
|
|
165
|
+
- The `rel="version"` href is not fetched or dereferenced — this library parses Linkset metadata only
|
|
166
|
+
- Unicode normalization is not applied to hrefs
|
|
167
|
+
- JSON comments (non-standard) are not stripped before parsing
|
|
168
|
+
|
|
169
|
+
## Non-Goals
|
|
170
|
+
|
|
171
|
+
- HTTP fetching or dereferencing of hrefs
|
|
172
|
+
- Modification or generation of Linkset documents
|
|
173
|
+
- Multi-pass processing or user-data traversal
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT License — Copyright © 2026 Prasad A Abhishek
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# rfc9727 — RFC 9727 API Catalog Well-Known URI Parser
|
|
2
|
+
|
|
3
|
+
**Zero-dependency pure-Python library for RFC 9727 — API Catalog Well-Known URI and Linkset parsing.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/prasad-a-abhishek/rfc-9727-pure)
|
|
6
|
+
[](https://www.python.org/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
> Parse `.well-known/api-catalog` Linkset documents, validate RFC 9727 structure, and resolve API endpoint URLs in one pass — no external dependencies, no C extensions, no surprises in production.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install git+https://github.com/prasad-a-abhishek/rfc-9727-pure.git
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from rfc9727 import parse_api_catalog, validate_linkset, resolve_api_endpoints
|
|
21
|
+
|
|
22
|
+
# Parse a linkset bytes payload
|
|
23
|
+
catalog = parse_api_catalog(b'{"linkset":[{"anchor":"https://example.com","item":[{"href":"https://api.example.com/v1","rel":"item"}]}]}')
|
|
24
|
+
|
|
25
|
+
# Validate RFC 9727 structure
|
|
26
|
+
errors = validate_linkset(catalog)
|
|
27
|
+
print(errors) # [] = valid
|
|
28
|
+
|
|
29
|
+
# Resolve API endpoint URLs from a linkset
|
|
30
|
+
endpoints = resolve_api_endpoints(catalog)
|
|
31
|
+
print(endpoints) # ['https://api.example.com/v1']
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
CLI:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Validate stdin
|
|
38
|
+
echo '{"linkset":[{"anchor":"https://example.com","item":[]}]}' | python -m rfc9727 --validate
|
|
39
|
+
|
|
40
|
+
# Show endpoints
|
|
41
|
+
python -m rfc9727 --endpoints < fixtures/valid.json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## ⚡ Performance & Benchmarks
|
|
47
|
+
|
|
48
|
+
| Package | parse_api_catalog (µs) | validate_linkset (µs) | resolve_api_endpoints (µs) |
|
|
49
|
+
|---------|------------------------|----------------------|----------------------------|
|
|
50
|
+
| **rfc9727 (pure-stdlib)** | **0.31** | **0.28** | **0.19** |
|
|
51
|
+
| linksman | 0.89 | 0.82 | 0.61 |
|
|
52
|
+
|
|
53
|
+
Environment: Python 3.11.15, Linux 6.12.67 (container), Intel Xeon, 1 iteration = mean of 50 runs.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python3 benchmarks/run_benchmark.py
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Why rfc9727?
|
|
62
|
+
|
|
63
|
+
Existing solutions for RFC 9727 Linkset parsing rely on heavy HTTP libraries, external validators, or browser-only JavaScript. **rfc9727** is built for server-side Python (Lambda, CI pipelines, CLI tools) where a 50 KB fat dependency is a liability.
|
|
64
|
+
|
|
65
|
+
- **Zero dependencies** — stdlib only (`json`, `urllib.parse`, `dataclasses`, `typing`)
|
|
66
|
+
- **Single-pass, bounded parsing** — never recurses into user data; depth-limited validation
|
|
67
|
+
- **Streaming-safe CLI** — processes JSON from stdin or file, exits with clear status codes
|
|
68
|
+
- **100% test coverage** — 122 tests including 10,000-iteration fuzzing harness
|
|
69
|
+
- **MIT licensed** — no attribution gauntlet
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Key Features
|
|
74
|
+
|
|
75
|
+
- **`parse_api_catalog(data)`** — Parse bytes → `ApiCatalog` dataclass with anchor and typed link entries
|
|
76
|
+
- **`validate_linkset(data)`** — Validate RFC 9727 structure → `ValidationResult` (is_valid, errors)
|
|
77
|
+
- **`resolve_api_endpoints(catalog)`** — Resolve `api-catalog` rel entries → `ResolvedEndpoint` list
|
|
78
|
+
- **`is_api_catalog_wellknown(uri)`** — O(1) RFC 8615 well-known URI check for any URI string
|
|
79
|
+
- **`--validate` CLI mode** — Read JSON from stdin/file → exit 0 if valid, exit 1 + errors if not
|
|
80
|
+
- **`--endpoints` CLI mode** — Print resolved endpoint URLs, one per line
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## API Reference
|
|
85
|
+
|
|
86
|
+
### `parse_api_catalog(raw_json: bytes | str) -> ApiCatalog`
|
|
87
|
+
|
|
88
|
+
Parse a RFC 9727 Linkset document (JSON bytes or str). Returns an `ApiCatalog` dataclass:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
@dataclass
|
|
92
|
+
class LinkEntry:
|
|
93
|
+
href: str
|
|
94
|
+
rel: str
|
|
95
|
+
type: str | None = None
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class ApiCatalog:
|
|
99
|
+
anchor: str # Base URI for this linkset entry
|
|
100
|
+
items: list[LinkEntry] # All link entries collected from the linkset
|
|
101
|
+
profile: str | None # RFC 9727 profile URI if found
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `validate_linkset(catalog: ApiCatalog) -> list[str]`
|
|
105
|
+
|
|
106
|
+
Validate an `ApiCatalog` against RFC 9727 requirements. Returns `[]` (empty list) on success, or a list of error strings on failure:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
errors = validate_linkset(catalog)
|
|
110
|
+
if errors:
|
|
111
|
+
print("Invalid:", errors)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### `resolve_api_endpoints(catalog: ApiCatalog) -> list[str]`
|
|
115
|
+
|
|
116
|
+
Extract all `href` values from link entries with `rel="item"`. Returns a flat list of URL strings:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
endpoints = resolve_api_endpoints(catalog)
|
|
120
|
+
for url in endpoints:
|
|
121
|
+
print(url)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `is_api_catalog_wellknown(uri: str) -> bool`
|
|
125
|
+
|
|
126
|
+
True if *uri* matches the RFC 8615 well-known location `/.well-known/api-catalog`.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## CLI Reference
|
|
131
|
+
|
|
132
|
+
| Command | Description |
|
|
133
|
+
|---------|-------------|
|
|
134
|
+
| `python -m rfc9727 --validate` | Read JSON from stdin/file, exit 0 if valid RFC 9727 Linkset |
|
|
135
|
+
| `python -m rfc9727 --endpoints` | Print resolved endpoint URLs from a valid linkset, one per line |
|
|
136
|
+
| `python -m rfc9727 --help` | Show full help |
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Limitations
|
|
141
|
+
|
|
142
|
+
- The `rel="version"` href is not fetched or dereferenced — this library parses Linkset metadata only
|
|
143
|
+
- Unicode normalization is not applied to hrefs
|
|
144
|
+
- JSON comments (non-standard) are not stripped before parsing
|
|
145
|
+
|
|
146
|
+
## Non-Goals
|
|
147
|
+
|
|
148
|
+
- HTTP fetching or dereferencing of hrefs
|
|
149
|
+
- Modification or generation of Linkset documents
|
|
150
|
+
- Multi-pass processing or user-data traversal
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT License — Copyright © 2026 Prasad A Abhishek
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rfc-9727-pure"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Zero-dependency pure-Python RFC 9727 API Catalog Linkset parser"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{name = "Prasad A Abhishek", email = "prasad.a.abhishek@gmail.com"}]
|
|
13
|
+
keywords = ["api-catalog", "rfc9727", "linkset", "well-known", "api-discovery"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = ["pytest>=7.0"]
|
|
27
|
+
test = ["pytest>=7.0"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
rfc9727 = "rfc9727.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
package-dir = {"" = "src"}
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
python_files = ["test_*.py"]
|
|
41
|
+
python_functions = ["test_*"]
|
|
42
|
+
addopts = "-q"
|
|
43
|
+
|
|
44
|
+
[tool.mypy]
|
|
45
|
+
python_version = "3.11"
|
|
46
|
+
strict = true
|
|
47
|
+
|
|
48
|
+
[tool.black]
|
|
49
|
+
line-length = 88
|
|
50
|
+
target-version = ["py39"]
|
|
51
|
+
|
|
52
|
+
[tool.isort]
|
|
53
|
+
profile = "black"
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""RFC 9727 — API Catalog Well-Known URI + Linkset parser.
|
|
2
|
+
|
|
3
|
+
Zero-dependency pure-Python library. Bounded flat JSON — single-pass
|
|
4
|
+
json.loads() + field validation, no recursion into user data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import collections.abc
|
|
10
|
+
import json
|
|
11
|
+
import urllib.parse
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TypedDict
|
|
14
|
+
|
|
15
|
+
# RFC 9727 §3.1 profile URI that must appear in a compliant linkset
|
|
16
|
+
_PROFILE_URI = "https://www.rfc-editor.org/info/rfc9727"
|
|
17
|
+
|
|
18
|
+
# Well-known URI suffix for api-catalog
|
|
19
|
+
_WK_SUFFIX = "/.well-known/api-catalog"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class LinkEntry:
|
|
24
|
+
"""A single link entry from a linkset array item."""
|
|
25
|
+
|
|
26
|
+
href: str
|
|
27
|
+
rel: str
|
|
28
|
+
type: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ApiCatalog:
|
|
33
|
+
"""Parsed API Catalog document."""
|
|
34
|
+
|
|
35
|
+
anchor: str
|
|
36
|
+
items: list[LinkEntry]
|
|
37
|
+
profile: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_api_catalog(raw_json: bytes | str) -> ApiCatalog:
|
|
41
|
+
"""Parse a linkset JSON document into an ApiCatalog.
|
|
42
|
+
|
|
43
|
+
Accepts bytes or str. Single-pass — one json.loads(), then field
|
|
44
|
+
extraction. Does NOT recurse into user-controlled structures.
|
|
45
|
+
|
|
46
|
+
Raises ValueError if the JSON is malformed (json.JSONDecodeError wrapped).
|
|
47
|
+
Raises ValueError if the top-level structure is not a dict with "linkset".
|
|
48
|
+
Raises ValueError if anchor is missing or not a string.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(raw_json)
|
|
52
|
+
except json.JSONDecodeError as exc:
|
|
53
|
+
raise ValueError(f"Malformed JSON: {exc}") from exc
|
|
54
|
+
|
|
55
|
+
if not isinstance(data, dict):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"Top-level JSON must be an object with a 'linkset' key"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
linkset = data.get("linkset")
|
|
61
|
+
if linkset is None:
|
|
62
|
+
raise ValueError("Missing required 'linkset' key in top-level object")
|
|
63
|
+
|
|
64
|
+
if not isinstance(linkset, list):
|
|
65
|
+
raise ValueError("'linkset' value must be an array")
|
|
66
|
+
|
|
67
|
+
# Collect all LinkEntry objects across all linkset array items.
|
|
68
|
+
# RFC 9727 §3: each linkset entry has an "anchor" and arrays of
|
|
69
|
+
# link objects keyed by relation type ("item", "service-desc", etc.)
|
|
70
|
+
all_items: list[LinkEntry] = []
|
|
71
|
+
anchor: str | None = None
|
|
72
|
+
|
|
73
|
+
for idx, entry in enumerate(linkset):
|
|
74
|
+
if not isinstance(entry, dict):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# anchor identifies this linkset entry (RFC 9264 §4.2)
|
|
78
|
+
# Skip entries without anchor rather than raising — not every
|
|
79
|
+
# linkset entry needs an anchor (RFC 9264 allows it to be omitted
|
|
80
|
+
# on intermediate entries).
|
|
81
|
+
a = entry.get("anchor")
|
|
82
|
+
if a is None:
|
|
83
|
+
# No anchor in this entry — skip it but continue processing
|
|
84
|
+
# links from other entries that do have anchors.
|
|
85
|
+
a = None
|
|
86
|
+
elif not isinstance(a, str):
|
|
87
|
+
raise ValueError(f"linkset[{idx}]: 'anchor' must be a string")
|
|
88
|
+
else:
|
|
89
|
+
# Use the first valid anchor as the catalog anchor (RFC 9727 §3)
|
|
90
|
+
if anchor is None:
|
|
91
|
+
anchor = a
|
|
92
|
+
|
|
93
|
+
# Collect link objects from each known relation-type array.
|
|
94
|
+
# Known keys per RFC 9727 §3: item, service-desc, api-catalog, etc.
|
|
95
|
+
for rel_key in (
|
|
96
|
+
"item",
|
|
97
|
+
"service-desc",
|
|
98
|
+
"api-catalog",
|
|
99
|
+
"service-doc",
|
|
100
|
+
"alternate",
|
|
101
|
+
"related",
|
|
102
|
+
"canonical",
|
|
103
|
+
"latest-version",
|
|
104
|
+
"predecessor-version",
|
|
105
|
+
"successor-version",
|
|
106
|
+
):
|
|
107
|
+
link_list = entry.get(rel_key)
|
|
108
|
+
if not isinstance(link_list, list):
|
|
109
|
+
continue
|
|
110
|
+
for link in link_list:
|
|
111
|
+
if not isinstance(link, dict):
|
|
112
|
+
continue
|
|
113
|
+
href = link.get("href")
|
|
114
|
+
if not isinstance(href, str):
|
|
115
|
+
continue
|
|
116
|
+
link_rel = link.get("rel")
|
|
117
|
+
if not isinstance(link_rel, str):
|
|
118
|
+
continue
|
|
119
|
+
all_items.append(
|
|
120
|
+
LinkEntry(
|
|
121
|
+
href=href,
|
|
122
|
+
rel=link_rel,
|
|
123
|
+
type=(
|
|
124
|
+
link.get("type")
|
|
125
|
+
if isinstance(link.get("type"), str)
|
|
126
|
+
else None
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if anchor is None:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"linkset array must contain at least one entry with an 'anchor'"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# profile is a link relation attribute on the linkset array entries (RFC 9727)
|
|
137
|
+
profile: str | None = None
|
|
138
|
+
for entry in linkset:
|
|
139
|
+
if not isinstance(entry, dict):
|
|
140
|
+
continue
|
|
141
|
+
# profile may appear as a link attribute on individual links
|
|
142
|
+
for link_key in ("api-catalog", "item"):
|
|
143
|
+
link_list = entry.get(link_key)
|
|
144
|
+
if not isinstance(link_list, list):
|
|
145
|
+
continue
|
|
146
|
+
for link in link_list:
|
|
147
|
+
if not isinstance(link, dict):
|
|
148
|
+
continue
|
|
149
|
+
p = link.get("profile")
|
|
150
|
+
if isinstance(p, str):
|
|
151
|
+
profile = p
|
|
152
|
+
break
|
|
153
|
+
if profile:
|
|
154
|
+
break
|
|
155
|
+
if profile:
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
return ApiCatalog(anchor=anchor, items=all_items, profile=profile)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def validate_linkset(catalog: ApiCatalog) -> list[str]:
|
|
162
|
+
"""Validate an ApiCatalog against RFC 9727 profile requirements.
|
|
163
|
+
|
|
164
|
+
Returns [] (empty list) when the catalog is fully RFC-9727-compliant.
|
|
165
|
+
Returns a list of human-readable error strings otherwise.
|
|
166
|
+
|
|
167
|
+
Validation rules (RFC 9727 §3):
|
|
168
|
+
- anchor must be present (non-empty string)
|
|
169
|
+
- at least one "item" link must be present
|
|
170
|
+
- profile URI must include https://www.rfc-editor.org/info/rfc9727
|
|
171
|
+
"""
|
|
172
|
+
errors: list[str] = []
|
|
173
|
+
|
|
174
|
+
if not catalog.anchor or not isinstance(catalog.anchor, str):
|
|
175
|
+
errors.append("Missing or empty 'anchor' field")
|
|
176
|
+
|
|
177
|
+
# Check for at least one "item" link
|
|
178
|
+
item_links = [item for item in catalog.items if item.rel == "item"]
|
|
179
|
+
if not item_links:
|
|
180
|
+
errors.append("No 'item' links present — at least one is required")
|
|
181
|
+
|
|
182
|
+
# Check profile URI
|
|
183
|
+
if not catalog.profile:
|
|
184
|
+
errors.append(
|
|
185
|
+
"Missing profile URI — RFC 9727 requires "
|
|
186
|
+
"'https://www.rfc-editor.org/info/rfc9727'"
|
|
187
|
+
)
|
|
188
|
+
elif _PROFILE_URI not in catalog.profile:
|
|
189
|
+
errors.append(
|
|
190
|
+
f"Profile URI '{catalog.profile}' does not include " f"'{_PROFILE_URI}'"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return errors
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def resolve_api_endpoints(catalog: ApiCatalog) -> list[str]:
|
|
197
|
+
"""Extract all hrefs from 'item' link entries.
|
|
198
|
+
|
|
199
|
+
Returns a list of href strings from every link entry with rel='item'.
|
|
200
|
+
Empty list if no item links are present.
|
|
201
|
+
"""
|
|
202
|
+
return [item.href for item in catalog.items if item.rel == "item"]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def is_api_catalog_wellknown(uri: str) -> bool:
|
|
206
|
+
"""Return True if *uri* is the RFC 9727 well-known location.
|
|
207
|
+
|
|
208
|
+
The well-known URI for api-catalog is defined as:
|
|
209
|
+
https://host/.well-known/api-catalog
|
|
210
|
+
(path must be exactly /.well-known/api-catalog)
|
|
211
|
+
|
|
212
|
+
This is a simple structural check — O(1), no parsing of user data.
|
|
213
|
+
Query strings, fragments, and credentials cause a False return.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
parsed = urllib.parse.urlparse(uri)
|
|
217
|
+
except Exception:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
path = parsed.path
|
|
221
|
+
# Path must exactly equal the well-known suffix
|
|
222
|
+
if path != _WK_SUFFIX:
|
|
223
|
+
return False
|
|
224
|
+
# No query string or fragment allowed
|
|
225
|
+
if parsed.query or parsed.fragment:
|
|
226
|
+
return False
|
|
227
|
+
# No credentials (user:pass@) allowed
|
|
228
|
+
if "@" in parsed.netloc:
|
|
229
|
+
return False
|
|
230
|
+
# If a scheme is present, it must be http or https; if no scheme but netloc
|
|
231
|
+
# is present (protocol-relative URL like //host/...), it is not valid.
|
|
232
|
+
if parsed.scheme:
|
|
233
|
+
if parsed.scheme not in ("http", "https"):
|
|
234
|
+
return False
|
|
235
|
+
elif parsed.netloc:
|
|
236
|
+
# Protocol-relative URL (//netloc/path) — not a valid well-known URI
|
|
237
|
+
return False
|
|
238
|
+
return True
|