jentic-openapi-validator 1.0.0a7__py3-none-any.whl
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.
- jentic/apitools/openapi/validator/backends/base.py +34 -0
- jentic/apitools/openapi/validator/backends/default/__init__.py +204 -0
- jentic/apitools/openapi/validator/backends/default/rules/__init__.py +182 -0
- jentic/apitools/openapi/validator/backends/default/rules/security.py +200 -0
- jentic/apitools/openapi/validator/backends/default/rules/server.py +136 -0
- jentic/apitools/openapi/validator/backends/default/rules/structural.py +109 -0
- jentic/apitools/openapi/validator/backends/openapi_spec.py +107 -0
- jentic/apitools/openapi/validator/backends/py.typed +0 -0
- jentic/apitools/openapi/validator/core/__init__.py +5 -0
- jentic/apitools/openapi/validator/core/diagnostics.py +99 -0
- jentic/apitools/openapi/validator/core/openapi_validator.py +191 -0
- jentic/apitools/openapi/validator/core/py.typed +0 -0
- jentic_openapi_validator-1.0.0a7.dist-info/METADATA +224 -0
- jentic_openapi_validator-1.0.0a7.dist-info/RECORD +18 -0
- jentic_openapi_validator-1.0.0a7.dist-info/WHEEL +4 -0
- jentic_openapi_validator-1.0.0a7.dist-info/entry_points.txt +5 -0
- jentic_openapi_validator-1.0.0a7.dist-info/licenses/LICENSE +202 -0
- jentic_openapi_validator-1.0.0a7.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server validation rules for OpenAPI specifications.
|
|
3
|
+
|
|
4
|
+
These rules validate the 'servers' section and individual server objects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from lsprotocol import types as lsp
|
|
10
|
+
|
|
11
|
+
from . import BaseRule, ValidationIssue
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["ServersArrayRule", "ServerUrlRule"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ServersArrayRule(BaseRule):
|
|
18
|
+
"""
|
|
19
|
+
Validates that the OpenAPI specification contains at least one server.
|
|
20
|
+
|
|
21
|
+
The 'servers' array is required and must contain at least one valid server object.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def rule_id(self) -> str:
|
|
26
|
+
return "servers-array"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
return "Servers Array Validation"
|
|
31
|
+
|
|
32
|
+
def validate(self, spec_data: dict[str, Any]) -> list[ValidationIssue]:
|
|
33
|
+
issues: list[ValidationIssue] = []
|
|
34
|
+
servers = spec_data.get("servers")
|
|
35
|
+
|
|
36
|
+
if not isinstance(servers, list) or not servers:
|
|
37
|
+
issues.append(
|
|
38
|
+
ValidationIssue(
|
|
39
|
+
code="MISSING_SERVER_URL",
|
|
40
|
+
message="OpenAPI spec must define at least one server in the 'servers' array.",
|
|
41
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
42
|
+
path=["servers"],
|
|
43
|
+
fixable=False,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return issues
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ServerUrlRule(BaseRule):
|
|
51
|
+
"""
|
|
52
|
+
Validates individual server objects and their URLs.
|
|
53
|
+
|
|
54
|
+
Each server must:
|
|
55
|
+
- Be a valid object (dict)
|
|
56
|
+
- Have a non-empty 'url' field
|
|
57
|
+
- Use an absolute URL (http://, https://, or template variable)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def rule_id(self) -> str:
|
|
62
|
+
return "server-url"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def name(self) -> str:
|
|
66
|
+
return "Server URL Validation"
|
|
67
|
+
|
|
68
|
+
def validate(self, spec_data: dict[str, Any]) -> list[ValidationIssue]:
|
|
69
|
+
issues: list[ValidationIssue] = []
|
|
70
|
+
servers = spec_data.get("servers")
|
|
71
|
+
|
|
72
|
+
# Only validate if servers is a list
|
|
73
|
+
if not isinstance(servers, list):
|
|
74
|
+
return issues
|
|
75
|
+
|
|
76
|
+
for index, server in enumerate(servers):
|
|
77
|
+
issues.extend(self._validate_single_server(server, index))
|
|
78
|
+
|
|
79
|
+
return issues
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _validate_single_server(server: Any, index: int) -> list[ValidationIssue]:
|
|
83
|
+
"""
|
|
84
|
+
Validates a single server object.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
server: The server object to validate
|
|
88
|
+
index: The index of the server in the servers array
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
List of ValidationIssue objects for any problems found
|
|
92
|
+
"""
|
|
93
|
+
issues: list[ValidationIssue] = []
|
|
94
|
+
server_path = f"#/servers/{index}"
|
|
95
|
+
|
|
96
|
+
# Check if server is a dict
|
|
97
|
+
if not isinstance(server, dict):
|
|
98
|
+
issues.append(
|
|
99
|
+
ValidationIssue(
|
|
100
|
+
code="INVALID_SERVER_OBJECT_FORMAT",
|
|
101
|
+
message=f"Server entry at index {index} is not a valid object.",
|
|
102
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
103
|
+
path=["servers", index],
|
|
104
|
+
fixable=False,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return issues
|
|
108
|
+
|
|
109
|
+
# Check for URL field
|
|
110
|
+
url = server.get("url")
|
|
111
|
+
|
|
112
|
+
if not url or not isinstance(url, str):
|
|
113
|
+
issues.append(
|
|
114
|
+
ValidationIssue(
|
|
115
|
+
code="SERVER_URL_MISSING_OR_EMPTY",
|
|
116
|
+
message=f"Server entry at '{server_path}' must have a non-empty 'url' string.",
|
|
117
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
118
|
+
path=["servers", index, "url"],
|
|
119
|
+
fixable=False,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return issues
|
|
123
|
+
|
|
124
|
+
# Check if URL is absolute (or uses template variables)
|
|
125
|
+
if not (url.startswith("http://") or url.startswith("https://") or url.startswith("{")):
|
|
126
|
+
issues.append(
|
|
127
|
+
ValidationIssue(
|
|
128
|
+
code="RELATIVE_SERVER_URL",
|
|
129
|
+
message=f"Server URL '{url}' at index {index} must be an absolute URL (e.g., start with http:// or https://).",
|
|
130
|
+
severity=lsp.DiagnosticSeverity.Warning,
|
|
131
|
+
path=["servers", index, "url"],
|
|
132
|
+
fixable=True,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return issues
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structural validation rules for OpenAPI specifications.
|
|
3
|
+
|
|
4
|
+
These rules validate the basic structure and required fields of an OpenAPI document.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from lsprotocol import types as lsp
|
|
10
|
+
|
|
11
|
+
from . import BaseRule, ValidationIssue
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["InfoObjectRule", "PathsRule"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InfoObjectRule(BaseRule):
|
|
18
|
+
"""
|
|
19
|
+
Validates the 'info' object in an OpenAPI specification.
|
|
20
|
+
|
|
21
|
+
The 'info' object is required and must contain 'title' and 'version' fields.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def rule_id(self) -> str:
|
|
26
|
+
return "info-object"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
return "Info Object Validation"
|
|
31
|
+
|
|
32
|
+
def validate(self, spec_data: dict[str, Any]) -> list[ValidationIssue]:
|
|
33
|
+
issues: list[ValidationIssue] = []
|
|
34
|
+
|
|
35
|
+
info_object = spec_data.get("info")
|
|
36
|
+
|
|
37
|
+
# Check if info object exists and is a dict
|
|
38
|
+
if not isinstance(info_object, dict):
|
|
39
|
+
issues.append(
|
|
40
|
+
ValidationIssue(
|
|
41
|
+
code="OPENAPI_MISSING_INFO",
|
|
42
|
+
message="OpenAPI spec is missing the required 'info' section or it is not an object.",
|
|
43
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
44
|
+
path=["info"],
|
|
45
|
+
fixable=False,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
return issues
|
|
49
|
+
|
|
50
|
+
# Check for required fields: title and version
|
|
51
|
+
missing_fields = []
|
|
52
|
+
if not info_object.get("title"):
|
|
53
|
+
missing_fields.append("'title'")
|
|
54
|
+
if not info_object.get("version"):
|
|
55
|
+
missing_fields.append("'version'")
|
|
56
|
+
|
|
57
|
+
if missing_fields:
|
|
58
|
+
# Special case: if only x-jentic-source-url is present
|
|
59
|
+
if len(info_object.keys()) == 1 and "x-jentic-source-url" in info_object:
|
|
60
|
+
message = "The 'info' object only contains 'x-jentic-source-url' and is missing required fields 'title' and 'version'."
|
|
61
|
+
else:
|
|
62
|
+
message = (
|
|
63
|
+
f"The 'info' object is missing required field(s): {', '.join(missing_fields)}."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
issues.append(
|
|
67
|
+
ValidationIssue(
|
|
68
|
+
code="OPENAPI_MISSING_INFO_FIELDS",
|
|
69
|
+
message=message,
|
|
70
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
71
|
+
path=["info"],
|
|
72
|
+
fixable=False,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return issues
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PathsRule(BaseRule):
|
|
80
|
+
"""
|
|
81
|
+
Validates that the OpenAPI specification contains a 'paths' section.
|
|
82
|
+
|
|
83
|
+
The 'paths' section is required in OpenAPI 3.x specifications and should
|
|
84
|
+
contain at least one path definition.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def rule_id(self) -> str:
|
|
89
|
+
return "paths-section"
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def name(self) -> str:
|
|
93
|
+
return "Paths Section Validation"
|
|
94
|
+
|
|
95
|
+
def validate(self, spec_data: dict[str, Any]) -> list[ValidationIssue]:
|
|
96
|
+
issues: list[ValidationIssue] = []
|
|
97
|
+
|
|
98
|
+
if "paths" not in spec_data:
|
|
99
|
+
issues.append(
|
|
100
|
+
ValidationIssue(
|
|
101
|
+
code="OPENAPI_MISSING_PATHS",
|
|
102
|
+
message="OpenAPI spec is missing the required 'paths' section.",
|
|
103
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
104
|
+
path=["paths"],
|
|
105
|
+
fixable=False,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return issues
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import textwrap
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from lsprotocol import types as lsp
|
|
6
|
+
from openapi_spec_validator import OpenAPIV30SpecValidator, OpenAPIV31SpecValidator
|
|
7
|
+
|
|
8
|
+
from jentic.apitools.openapi.validator.backends.base import BaseValidatorBackend
|
|
9
|
+
from jentic.apitools.openapi.validator.core.diagnostics import JenticDiagnostic, ValidationResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = ["OpenAPISpecValidatorBackend"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OpenAPISpecValidatorBackend(BaseValidatorBackend):
|
|
16
|
+
def validate(
|
|
17
|
+
self, document: str | dict, *, base_url: str | None = None, target: str | None = None
|
|
18
|
+
) -> ValidationResult:
|
|
19
|
+
if not isinstance(document, dict):
|
|
20
|
+
diagnostic = JenticDiagnostic(
|
|
21
|
+
range=lsp.Range(
|
|
22
|
+
start=lsp.Position(line=0, character=0),
|
|
23
|
+
end=lsp.Position(line=0, character=12),
|
|
24
|
+
),
|
|
25
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
26
|
+
code="OAS1002",
|
|
27
|
+
source="openapi_spec_validator",
|
|
28
|
+
message="OpenAPISpecValidatorBackend only accepts dict format",
|
|
29
|
+
)
|
|
30
|
+
diagnostic.set_target(target)
|
|
31
|
+
return ValidationResult(diagnostics=[diagnostic])
|
|
32
|
+
|
|
33
|
+
if self._is_openapi_v31(document):
|
|
34
|
+
validator = OpenAPIV31SpecValidator(document, base_uri=base_url or "")
|
|
35
|
+
elif self._is_openapi_v30(document):
|
|
36
|
+
validator = OpenAPIV30SpecValidator(document, base_uri=base_url or "")
|
|
37
|
+
else:
|
|
38
|
+
diagnostic = JenticDiagnostic(
|
|
39
|
+
range=lsp.Range(
|
|
40
|
+
start=lsp.Position(line=0, character=0),
|
|
41
|
+
end=lsp.Position(line=0, character=12),
|
|
42
|
+
),
|
|
43
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
44
|
+
code="OAS1000",
|
|
45
|
+
# code_description=lsp.CodeDescription(href="https://example.com/rules/OAS1000"),
|
|
46
|
+
source="openapi_spec_validator",
|
|
47
|
+
message="Document does not appear to be a valid OpenAPI 3.0.x or 3.1.x specification",
|
|
48
|
+
)
|
|
49
|
+
diagnostic.set_target(target)
|
|
50
|
+
return ValidationResult(diagnostics=[diagnostic])
|
|
51
|
+
|
|
52
|
+
diagnostics: list[JenticDiagnostic] = []
|
|
53
|
+
try:
|
|
54
|
+
for error in validator.iter_errors():
|
|
55
|
+
diagnostic = JenticDiagnostic(
|
|
56
|
+
range=lsp.Range(
|
|
57
|
+
start=lsp.Position(line=0, character=0),
|
|
58
|
+
end=lsp.Position(line=0, character=0),
|
|
59
|
+
),
|
|
60
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
61
|
+
code=f"{error.validator or str(error.validator_value)}",
|
|
62
|
+
source="openapi-spec-validator",
|
|
63
|
+
message=error.message,
|
|
64
|
+
)
|
|
65
|
+
diagnostic.set_path(list(error.path))
|
|
66
|
+
diagnostic.set_target(target)
|
|
67
|
+
diagnostics.append(diagnostic)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
error_msg = textwrap.shorten(str(e), width=500, placeholder="...")
|
|
70
|
+
diagnostic = JenticDiagnostic(
|
|
71
|
+
range=lsp.Range(
|
|
72
|
+
start=lsp.Position(line=0, character=0),
|
|
73
|
+
end=lsp.Position(line=0, character=12),
|
|
74
|
+
),
|
|
75
|
+
severity=lsp.DiagnosticSeverity.Error,
|
|
76
|
+
code="openapi-spec-validator-error",
|
|
77
|
+
source="openapi-spec-validator",
|
|
78
|
+
message=f"Error validating spec - {error_msg}",
|
|
79
|
+
)
|
|
80
|
+
diagnostic.set_target(target)
|
|
81
|
+
diagnostics.append(diagnostic)
|
|
82
|
+
|
|
83
|
+
return ValidationResult(diagnostics)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def accepts() -> Sequence[Literal["dict"]]:
|
|
87
|
+
"""Return the document formats this validator can accept.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Sequence of supported document format identifiers:
|
|
91
|
+
- "dict": Python dictionary containing OpenAPI Document data
|
|
92
|
+
"""
|
|
93
|
+
return ["dict"]
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _is_openapi_v31(document: dict) -> bool:
|
|
97
|
+
if not isinstance(document, dict):
|
|
98
|
+
return False
|
|
99
|
+
openapi_version = document.get("openapi", "")
|
|
100
|
+
return isinstance(openapi_version, str) and openapi_version.startswith("3.1")
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _is_openapi_v30(document: dict) -> bool:
|
|
104
|
+
if not isinstance(document, dict):
|
|
105
|
+
return False
|
|
106
|
+
openapi_version = document.get("openapi", "")
|
|
107
|
+
return isinstance(openapi_version, str) and openapi_version.startswith("3.0")
|
|
File without changes
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from lsprotocol.types import Diagnostic
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
__all__ = ["JenticDiagnostic", "ValidationResult"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JenticDiagnostic(Diagnostic):
|
|
10
|
+
def __init__(self, **data):
|
|
11
|
+
super().__init__(**data)
|
|
12
|
+
if not hasattr(self, "data") or self.data is None:
|
|
13
|
+
self.data = {}
|
|
14
|
+
if "fixable" not in self.data:
|
|
15
|
+
self.data["fixable"] = True
|
|
16
|
+
if "path" not in self.data:
|
|
17
|
+
self.data["path"] = []
|
|
18
|
+
if "target" not in self.data:
|
|
19
|
+
self.data["target"] = ""
|
|
20
|
+
|
|
21
|
+
def set_fixable(self, fixable: bool = True):
|
|
22
|
+
if not hasattr(self, "data") or self.data is None:
|
|
23
|
+
self.data = {}
|
|
24
|
+
self.data["fixable"] = fixable
|
|
25
|
+
|
|
26
|
+
def set_path(self, path: list[str | int] | None):
|
|
27
|
+
if path is None:
|
|
28
|
+
return
|
|
29
|
+
if not hasattr(self, "data") or self.data is None:
|
|
30
|
+
self.data = {}
|
|
31
|
+
self.data["path"] = path
|
|
32
|
+
|
|
33
|
+
def set_target(self, target: str | None):
|
|
34
|
+
if target is None:
|
|
35
|
+
return
|
|
36
|
+
if not hasattr(self, "data") or self.data is None:
|
|
37
|
+
self.data = {}
|
|
38
|
+
self.data["target"] = target
|
|
39
|
+
|
|
40
|
+
def set_data_field(self, key: str, value: str | None):
|
|
41
|
+
if value is None:
|
|
42
|
+
return
|
|
43
|
+
if not hasattr(self, "data") or self.data is None:
|
|
44
|
+
self.data = {}
|
|
45
|
+
self.data[key] = value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ValidationResult:
|
|
50
|
+
"""
|
|
51
|
+
Represents the result of validating an OpenAPI document.
|
|
52
|
+
|
|
53
|
+
This class encapsulates all diagnostics (errors, warnings, etc.) produced
|
|
54
|
+
by validator backends and provides convenient methods to check validation
|
|
55
|
+
status and filter diagnostics by severity.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
diagnostics: List of all diagnostics from validation
|
|
59
|
+
valid: True if no diagnostics were found, False otherwise (computed automatically)
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
diagnostics: list[JenticDiagnostic] = field(default_factory=list)
|
|
63
|
+
valid: bool = field(init=False)
|
|
64
|
+
|
|
65
|
+
def __post_init__(self):
|
|
66
|
+
"""Compute the valid attribute after initialization."""
|
|
67
|
+
self.valid = len(self.diagnostics) == 0
|
|
68
|
+
|
|
69
|
+
def __bool__(self) -> bool:
|
|
70
|
+
"""
|
|
71
|
+
Allow ValidationResult to be used in boolean context.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if validation passed (no diagnostics), False otherwise
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
>>> result = validator.validate(document)
|
|
78
|
+
>>> if result:
|
|
79
|
+
... print("Validation passed!")
|
|
80
|
+
"""
|
|
81
|
+
return self.valid
|
|
82
|
+
|
|
83
|
+
def __len__(self) -> int:
|
|
84
|
+
"""
|
|
85
|
+
Return the number of diagnostics.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Count of all diagnostics
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
>>> result = validator.validate(document)
|
|
92
|
+
>>> print(f"Found {len(result)} issues")
|
|
93
|
+
"""
|
|
94
|
+
return len(self.diagnostics)
|
|
95
|
+
|
|
96
|
+
def __repr__(self) -> str:
|
|
97
|
+
"""Return a string representation of the validation result."""
|
|
98
|
+
status = "valid" if self.valid else "invalid"
|
|
99
|
+
return f"ValidationResult(status={status}, diagnostics={len(self.diagnostics)})"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import json
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Type
|
|
5
|
+
|
|
6
|
+
from jentic.apitools.openapi.parser.core import OpenAPIParser
|
|
7
|
+
from jentic.apitools.openapi.validator.backends.base import BaseValidatorBackend
|
|
8
|
+
|
|
9
|
+
from .diagnostics import JenticDiagnostic, ValidationResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = ["OpenAPIValidator"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Cache entry points at module level for performance
|
|
16
|
+
try:
|
|
17
|
+
_VALIDATOR_BACKENDS = {
|
|
18
|
+
ep.name: ep
|
|
19
|
+
for ep in importlib.metadata.entry_points(
|
|
20
|
+
group="jentic.apitools.openapi.validator.backends"
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
except Exception as e:
|
|
24
|
+
warnings.warn(f"Failed to load validator backend entry points: {e}", RuntimeWarning)
|
|
25
|
+
_VALIDATOR_BACKENDS = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OpenAPIValidator:
|
|
29
|
+
"""
|
|
30
|
+
Validates OpenAPI documents using pluggable validator backends.
|
|
31
|
+
|
|
32
|
+
This class provides a flexible validation framework that can use multiple
|
|
33
|
+
validator backends simultaneously. Backends can be specified by name (via
|
|
34
|
+
entry points), as class instances, or as class types.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
parser: OpenAPIParser instance for parsing and loading documents
|
|
38
|
+
backends: List of validator backend instances to use for validation
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
backends: list[str | BaseValidatorBackend | Type[BaseValidatorBackend]] | None = None,
|
|
44
|
+
parser: OpenAPIParser | None = None,
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
Initialize the OpenAPI validator.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
backends: List of validator backends to use. Each item can be:
|
|
51
|
+
- str: Name of a backend registered via entry points (e.g., "default", "openapi-spec", "spectral")
|
|
52
|
+
- BaseValidatorBackend: Instance of a validator backend
|
|
53
|
+
- Type[BaseValidatorBackend]: Class of a validator backend (will be instantiated)
|
|
54
|
+
Defaults to ["default"] if None.
|
|
55
|
+
parser: Custom OpenAPIParser instance. If None, creates a default parser.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If a backend name is not found in registered entry points
|
|
59
|
+
TypeError: If a backend is not a valid type (str, instance, or class)
|
|
60
|
+
"""
|
|
61
|
+
self.parser = parser if parser else OpenAPIParser()
|
|
62
|
+
self.backends: list[BaseValidatorBackend] = []
|
|
63
|
+
backends = ["default"] if not backends else backends
|
|
64
|
+
|
|
65
|
+
for backend in backends:
|
|
66
|
+
if isinstance(backend, str):
|
|
67
|
+
if backend in _VALIDATOR_BACKENDS:
|
|
68
|
+
backend_class = _VALIDATOR_BACKENDS[backend].load() # loads the class
|
|
69
|
+
self.backends.append(backend_class())
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError(f"No validator backend named '{backend}' found")
|
|
72
|
+
elif isinstance(backend, BaseValidatorBackend):
|
|
73
|
+
self.backends.append(backend)
|
|
74
|
+
elif isinstance(backend, type) and issubclass(backend, BaseValidatorBackend):
|
|
75
|
+
# Class (not instance) is passed
|
|
76
|
+
self.backends.append(backend())
|
|
77
|
+
else:
|
|
78
|
+
raise TypeError("Invalid backend type: must be name or backend class/instance")
|
|
79
|
+
|
|
80
|
+
def validate(
|
|
81
|
+
self, document: str | dict, *, base_url: str | None = None, target: str | None = None
|
|
82
|
+
) -> ValidationResult:
|
|
83
|
+
"""
|
|
84
|
+
Validate an OpenAPI document using all configured backends.
|
|
85
|
+
|
|
86
|
+
This method accepts OpenAPI documents in multiple formats and automatically
|
|
87
|
+
converts them to the format(s) required by each backend. All diagnostics
|
|
88
|
+
from all backends are aggregated into a single ValidationResult.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
document: OpenAPI document in one of the following formats:
|
|
92
|
+
- File URI (e.g., "file:///path/to/openapi.yaml")
|
|
93
|
+
- JSON/YAML string representation
|
|
94
|
+
- Python dictionary
|
|
95
|
+
base_url: Optional base URL for resolving relative references in the document
|
|
96
|
+
target: Optional target identifier for validation context
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
ValidationResult containing aggregated diagnostics from all backends.
|
|
100
|
+
The result's `valid` property indicates if validation passed.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
TypeError: If document is not a str or dict
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
diagnostics: list[JenticDiagnostic] = []
|
|
107
|
+
document_is_uri: bool = False
|
|
108
|
+
document_text: str = ""
|
|
109
|
+
document_dict: dict | None = None
|
|
110
|
+
|
|
111
|
+
# Determine an input type and prepare different representations
|
|
112
|
+
if isinstance(document, str):
|
|
113
|
+
document_is_uri = self.parser.is_uri_like(document)
|
|
114
|
+
|
|
115
|
+
if document_is_uri:
|
|
116
|
+
# Load URI content if any backend needs non-URI format
|
|
117
|
+
document_text = (
|
|
118
|
+
self.parser.load_uri(document) if self.has_non_uri_backend() else document
|
|
119
|
+
)
|
|
120
|
+
document_dict = (
|
|
121
|
+
self.parser.parse(document_text) if self.has_non_uri_backend() else None
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
# Plain text (JSON/YAML)
|
|
125
|
+
document_text = document
|
|
126
|
+
document_dict = self.parser.parse(document)
|
|
127
|
+
elif isinstance(document, dict):
|
|
128
|
+
document_is_uri = False
|
|
129
|
+
document_text = json.dumps(document)
|
|
130
|
+
document_dict = document
|
|
131
|
+
else:
|
|
132
|
+
raise TypeError(
|
|
133
|
+
f"Unsupported document type: {type(document).__name__!r}. "
|
|
134
|
+
f"Expected str (URI or JSON/YAML) or dict."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Run validation through all backends
|
|
138
|
+
for backend in self.backends:
|
|
139
|
+
accepted = backend.accepts()
|
|
140
|
+
backend_document = None
|
|
141
|
+
|
|
142
|
+
# Determine which format to pass to this backend
|
|
143
|
+
if document_is_uri and "uri" in accepted:
|
|
144
|
+
backend_document = document
|
|
145
|
+
elif "dict" in accepted and document_dict is not None:
|
|
146
|
+
backend_document = document_dict
|
|
147
|
+
elif "text" in accepted:
|
|
148
|
+
backend_document = document_text
|
|
149
|
+
|
|
150
|
+
if backend_document is not None:
|
|
151
|
+
result = backend.validate(backend_document, base_url=base_url, target=target)
|
|
152
|
+
diagnostics.extend(result.diagnostics)
|
|
153
|
+
|
|
154
|
+
return ValidationResult(diagnostics=diagnostics)
|
|
155
|
+
|
|
156
|
+
def has_non_uri_backend(self) -> bool:
|
|
157
|
+
"""
|
|
158
|
+
Check if any configured backend requires non-URI document format.
|
|
159
|
+
|
|
160
|
+
This helper method determines whether document content needs to be loaded
|
|
161
|
+
and parsed from a URI. If all backends accept URIs directly, the loading
|
|
162
|
+
step can be skipped for better performance.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True if at least one backend accepts 'text' or 'dict' but not 'uri'.
|
|
166
|
+
False if all backends can handle URIs directly.
|
|
167
|
+
"""
|
|
168
|
+
for backend in self.backends:
|
|
169
|
+
accepted = backend.accepts()
|
|
170
|
+
if ("text" in accepted or "dict" in accepted) and "uri" not in accepted:
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def list_backends() -> list[str]:
|
|
176
|
+
"""
|
|
177
|
+
List all available validator backends registered via entry points.
|
|
178
|
+
|
|
179
|
+
This static method discovers and returns the names of all validator backends
|
|
180
|
+
that have been registered in the 'jentic.apitools.openapi.validator.backends'
|
|
181
|
+
entry point group.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
List of backend names that can be used when initializing OpenAPIValidator.
|
|
185
|
+
|
|
186
|
+
Example:
|
|
187
|
+
>>> backends = OpenAPIValidator.list_backends()
|
|
188
|
+
>>> print(backends)
|
|
189
|
+
['default', 'spectral']
|
|
190
|
+
"""
|
|
191
|
+
return list(_VALIDATOR_BACKENDS.keys())
|
|
File without changes
|