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.
@@ -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,5 @@
1
+ from .diagnostics import JenticDiagnostic, ValidationResult
2
+ from .openapi_validator import OpenAPIValidator
3
+
4
+
5
+ __all__ = ["ValidationResult", "JenticDiagnostic", "OpenAPIValidator"]
@@ -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