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,34 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Sequence
5
+ from typing import TYPE_CHECKING
6
+
7
+
8
+ if TYPE_CHECKING:
9
+ from jentic.apitools.openapi.validator.core.diagnostics import ValidationResult
10
+
11
+
12
+ __all__ = ["BaseValidatorBackend"]
13
+
14
+
15
+ class BaseValidatorBackend(ABC):
16
+ """Interface that all Validator backends must implement."""
17
+
18
+ @abstractmethod
19
+ def validate(
20
+ self, document: str | dict, *, base_url: str | None = None, target: str | None = None
21
+ ) -> ValidationResult:
22
+ """Validate an OpenAPI document given by URI or file path or text.
23
+ Returns a ValidationResult (could be a list of errors, or an object)."""
24
+ ...
25
+
26
+ @staticmethod
27
+ @abstractmethod
28
+ def accepts() -> Sequence[str]:
29
+ """Return the document formats this backend can accept.
30
+
31
+ Returns:
32
+ Sequence of format identifiers (e.g., "uri", "text", "dict")
33
+ """
34
+ ...
@@ -0,0 +1,204 @@
1
+ """
2
+ Default OpenAPI validator backend with rule-based validation system.
3
+
4
+ This backend provides a collection of validation rules for common OpenAPI
5
+ specification issues including structural validation, server validation,
6
+ and security validation.
7
+ """
8
+
9
+ from typing import Literal
10
+
11
+ from lsprotocol import types as lsp
12
+
13
+ from jentic.apitools.openapi.parser.core import OpenAPIParser
14
+ from jentic.apitools.openapi.parser.core.exceptions import DocumentLoadError, DocumentParseError
15
+
16
+ from ...core.diagnostics import JenticDiagnostic, ValidationResult
17
+ from ..base import BaseValidatorBackend
18
+ from .rules import RuleRegistry
19
+ from .rules.security import SecuritySchemeReferenceRule, UnusedSecuritySchemeRule
20
+ from .rules.server import ServerUrlRule
21
+ from .rules.structural import InfoObjectRule, PathsRule
22
+
23
+
24
+ __all__ = ["DefaultOpenAPIValidatorBackend"]
25
+
26
+
27
+ class DefaultOpenAPIValidatorBackend(BaseValidatorBackend):
28
+ """
29
+ Default OpenAPI validator backend using a rule-based validation system.
30
+
31
+ This validator applies a set of predefined rules to check for common
32
+ issues in OpenAPI specifications. Rules cover:
33
+ - Structural validation (info, paths)
34
+ - Server validation (servers array, URLs)
35
+ - Security validation (scheme references, unused schemes)
36
+
37
+ The validator can be customized by providing a custom RuleRegistry
38
+ with different rules.
39
+ """
40
+
41
+ def __init__(
42
+ self, rule_registry: RuleRegistry | None = None, parser: OpenAPIParser | None = None
43
+ ):
44
+ """
45
+ Initialize the default OpenAPI validator.
46
+
47
+ Args:
48
+ rule_registry: Optional custom rule registry. If None, uses default rules.
49
+ parser: Optional OpenAPIParser instance. If None, creates a default parser.
50
+ """
51
+ if rule_registry is None:
52
+ rule_registry = self._create_default_registry()
53
+ self.registry = rule_registry
54
+ self.parser = parser if parser else OpenAPIParser()
55
+
56
+ def validate(
57
+ self, document: str | dict, *, base_url: str | None = None, target: str | None = None
58
+ ) -> ValidationResult:
59
+ """
60
+ Validate an OpenAPI document using the registered rules.
61
+
62
+ Args:
63
+ document: Path to the OpenAPI document file to validate, or dict containing the document
64
+ base_url: Optional base URL for resolving references
65
+ target: Optional target identifier for validation context
66
+
67
+ Returns:
68
+ ValidationResult containing diagnostics for all rule violations
69
+
70
+ Raises:
71
+ TypeError: If document type is not supported
72
+ """
73
+ if isinstance(document, str):
74
+ return self._validate_uri(document, base_url=base_url, target=target)
75
+ elif isinstance(document, dict):
76
+ return self._validate_dict(document, base_url=base_url, target=target)
77
+ else:
78
+ raise TypeError(f"Unsupported document type: {type(document)!r}")
79
+
80
+ @staticmethod
81
+ def accepts() -> list[Literal["uri", "dict"]]:
82
+ """
83
+ Return the document formats this validator accepts.
84
+
85
+ Returns:
86
+ Sequence of supported document format identifiers:
87
+ - "uri": File path or URI pointing to OpenAPI Document
88
+ - "dict": Python dictionary containing OpenAPI Document data
89
+ """
90
+ return ["uri", "dict"]
91
+
92
+ def _validate_uri(
93
+ self, document: str, *, base_url: str | None = None, target: str | None = None
94
+ ) -> ValidationResult:
95
+ """
96
+ Validate an OpenAPI document from a URI or file path.
97
+
98
+ Args:
99
+ document: Path to the OpenAPI document file or URI
100
+ base_url: Optional base URL for resolving references
101
+ target: Optional target identifier for validation context
102
+
103
+ Returns:
104
+ ValidationResult containing diagnostics for all rule violations
105
+ """
106
+ try:
107
+ # Check if parser backend supports URI directly
108
+ if "uri" in self.parser.backend.accepts():
109
+ # Let parser handle URI loading
110
+ document_dict = self.parser.parse(document)
111
+ else:
112
+ # Manually load URI and parse the text
113
+ document_text = self.parser.load_uri(document)
114
+ document_dict = self.parser.parse(document_text)
115
+
116
+ # Validate the parsed document
117
+ return self._validate_dict(document_dict, base_url=base_url, target=target)
118
+
119
+ except (DocumentParseError, DocumentLoadError) as e:
120
+ # Handle document parsing/loading errors
121
+ diagnostic = JenticDiagnostic(
122
+ range=lsp.Range(
123
+ start=lsp.Position(line=0, character=0),
124
+ end=lsp.Position(line=0, character=12),
125
+ ),
126
+ severity=lsp.DiagnosticSeverity.Error,
127
+ code="document-parse-error",
128
+ source="default-validator",
129
+ message=f"Failed to parse document: {str(e)}",
130
+ )
131
+ diagnostic.set_target(target)
132
+ return ValidationResult(diagnostics=[diagnostic])
133
+ except Exception as e:
134
+ # Handle any other unexpected errors
135
+ diagnostic = JenticDiagnostic(
136
+ range=lsp.Range(
137
+ start=lsp.Position(line=0, character=0),
138
+ end=lsp.Position(line=0, character=12),
139
+ ),
140
+ severity=lsp.DiagnosticSeverity.Error,
141
+ code="default-validator-error",
142
+ source="default-validator",
143
+ message=f"Unexpected error: {str(e)}",
144
+ )
145
+ diagnostic.set_target(target)
146
+ return ValidationResult(diagnostics=[diagnostic])
147
+
148
+ def _validate_dict(
149
+ self, document: dict, *, base_url: str | None = None, target: str | None = None
150
+ ) -> ValidationResult:
151
+ """
152
+ Validate an OpenAPI document from a dictionary.
153
+
154
+ Args:
155
+ document: The OpenAPI document as a dictionary
156
+ base_url: Optional base URL for resolving references (not used)
157
+ target: Optional target identifier for validation context
158
+
159
+ Returns:
160
+ ValidationResult containing diagnostics for all rule violations
161
+ """
162
+ try:
163
+ # Run all rules through the registry
164
+ diagnostics = self.registry.validate(document, target=target)
165
+ return ValidationResult(diagnostics=diagnostics)
166
+
167
+ except Exception as e:
168
+ # Catch any unexpected errors during validation
169
+ msg = str(e)
170
+ diagnostic = JenticDiagnostic(
171
+ range=lsp.Range(
172
+ start=lsp.Position(line=0, character=0),
173
+ end=lsp.Position(line=0, character=12),
174
+ ),
175
+ severity=lsp.DiagnosticSeverity.Error,
176
+ code="default-validator-error",
177
+ source="default-validator",
178
+ message=msg,
179
+ )
180
+ diagnostic.set_target(target)
181
+ return ValidationResult(diagnostics=[diagnostic])
182
+
183
+ @staticmethod
184
+ def _create_default_registry() -> RuleRegistry:
185
+ """
186
+ Create the default rule registry with all standard rules.
187
+
188
+ Returns:
189
+ A RuleRegistry with all default validation rules registered
190
+ """
191
+ registry = RuleRegistry(source="default-validator")
192
+
193
+ # Register structural rules
194
+ registry.register(InfoObjectRule())
195
+ registry.register(PathsRule())
196
+
197
+ # Register server rules (only validate format if servers exist)
198
+ registry.register(ServerUrlRule())
199
+
200
+ # Register security rules
201
+ registry.register(SecuritySchemeReferenceRule())
202
+ registry.register(UnusedSecuritySchemeRule())
203
+
204
+ return registry
@@ -0,0 +1,182 @@
1
+ """
2
+ Validation rules system for the default OpenAPI validator backend.
3
+
4
+ This module provides the infrastructure for defining and executing validation rules
5
+ on OpenAPI specifications. Rules are modular, testable, and composable.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from lsprotocol import types as lsp
13
+
14
+ from ....core.diagnostics import JenticDiagnostic
15
+
16
+
17
+ __all__ = ["BaseRule", "RuleRegistry", "ValidationIssue"]
18
+
19
+
20
+ @dataclass
21
+ class ValidationIssue:
22
+ """
23
+ Represents a validation issue found by a rule.
24
+
25
+ This is a lightweight data structure that captures the essential information
26
+ about a validation problem. It will be converted to a JenticDiagnostic
27
+ by the rule registry.
28
+
29
+ Attributes:
30
+ code: Error code (e.g., "MISSING_SERVER_URL")
31
+ message: Human-readable error message
32
+ severity: Diagnostic severity level
33
+ path: JSON path to the problematic element (e.g., ["servers", 0, "url"])
34
+ fixable: Whether this issue can be automatically fixed
35
+ """
36
+
37
+ code: str
38
+ message: str
39
+ severity: lsp.DiagnosticSeverity = lsp.DiagnosticSeverity.Error
40
+ path: list[str | int] = field(default_factory=list)
41
+ fixable: bool = True
42
+
43
+ def to_diagnostic(self, source: str, target: str | None = None) -> JenticDiagnostic:
44
+ """
45
+ Convert this ValidationIssue to a JenticDiagnostic.
46
+
47
+ Args:
48
+ source: Source identifier for the diagnostic (e.g., "default-validator")
49
+ target: Optional target identifier for validation context
50
+
51
+ Returns:
52
+ A JenticDiagnostic instance ready to be returned to the user
53
+ """
54
+ diagnostic = JenticDiagnostic(
55
+ range=lsp.Range(
56
+ start=lsp.Position(line=0, character=0),
57
+ end=lsp.Position(line=0, character=0),
58
+ ),
59
+ severity=self.severity,
60
+ code=self.code,
61
+ source=source,
62
+ message=self.message,
63
+ )
64
+ diagnostic.set_path(self.path)
65
+ diagnostic.set_target(target)
66
+ diagnostic.set_fixable(self.fixable)
67
+ return diagnostic
68
+
69
+
70
+ class BaseRule(ABC):
71
+ """
72
+ Abstract base class for validation rules.
73
+
74
+ Each rule should focus on validating a specific aspect of the OpenAPI specification.
75
+ Rules are designed to be stateless and reusable.
76
+
77
+ Subclasses must implement the `validate` method which examines the spec
78
+ and returns a list of ValidationIssue objects for any problems found.
79
+ """
80
+
81
+ @property
82
+ @abstractmethod
83
+ def rule_id(self) -> str:
84
+ """
85
+ Return a machine-readable identifier for this rule.
86
+
87
+ The rule ID should be in dash-case (kebab-case) format for consistency
88
+ with other validation tools like Redocly.
89
+
90
+ Returns:
91
+ Rule identifier (e.g., "server-url-validation")
92
+ """
93
+ ...
94
+
95
+ @property
96
+ @abstractmethod
97
+ def name(self) -> str:
98
+ """
99
+ Return a human-readable name for this rule.
100
+
101
+ Returns:
102
+ Rule name (e.g., "Server URL Validation")
103
+ """
104
+ ...
105
+
106
+ @abstractmethod
107
+ def validate(self, spec_data: dict[str, Any]) -> list[ValidationIssue]:
108
+ """
109
+ Validate the OpenAPI specification.
110
+
111
+ Args:
112
+ spec_data: The parsed OpenAPI specification as a dictionary
113
+
114
+ Returns:
115
+ List of ValidationIssue objects for any problems found.
116
+ Empty list if no issues.
117
+ """
118
+ ...
119
+
120
+
121
+ class RuleRegistry:
122
+ """
123
+ Registry for managing and executing validation rules.
124
+
125
+ The registry maintains a collection of validation rules and provides
126
+ methods to execute them against OpenAPI specifications.
127
+ """
128
+
129
+ def __init__(self, source: str = "default-validator"):
130
+ """
131
+ Initialize the rule registry.
132
+
133
+ Args:
134
+ source: Source identifier for diagnostics (default: "default-validator")
135
+ """
136
+ self.source = source
137
+ self.rules: list[BaseRule] = []
138
+
139
+ def register(self, rule: BaseRule) -> None:
140
+ """
141
+ Register a validation rule.
142
+
143
+ Args:
144
+ rule: The rule to register
145
+ """
146
+ self.rules.append(rule)
147
+
148
+ def register_all(self, rules: list[BaseRule]) -> None:
149
+ """
150
+ Register multiple validation rules at once.
151
+
152
+ Args:
153
+ rules: List of rules to register
154
+ """
155
+ self.rules.extend(rules)
156
+
157
+ def validate(
158
+ self, spec_data: dict[str, Any], target: str | None = None
159
+ ) -> list[JenticDiagnostic]:
160
+ """
161
+ Run all registered rules against the specification.
162
+
163
+ Args:
164
+ spec_data: The parsed OpenAPI specification as a dictionary
165
+ target: Optional target identifier for validation context
166
+
167
+ Returns:
168
+ List of JenticDiagnostic objects for all issues found across all rules
169
+ """
170
+ diagnostics: list[JenticDiagnostic] = []
171
+
172
+ for rule in self.rules:
173
+ issues = rule.validate(spec_data)
174
+ for issue in issues:
175
+ diagnostic = issue.to_diagnostic(source=self.source, target=target)
176
+ diagnostics.append(diagnostic)
177
+
178
+ return diagnostics
179
+
180
+ def clear(self) -> None:
181
+ """Clear all registered rules."""
182
+ self.rules.clear()
@@ -0,0 +1,200 @@
1
+ """
2
+ Security validation rules for OpenAPI specifications.
3
+
4
+ These rules validate security schemes and their usage throughout the specification.
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__ = ["SecuritySchemeReferenceRule", "UnusedSecuritySchemeRule"]
15
+
16
+
17
+ # HTTP methods defined in OpenAPI 3.x specification
18
+ _HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
19
+
20
+
21
+ class SecuritySchemeReferenceRule(BaseRule):
22
+ """
23
+ Validates that all security scheme references point to defined schemes.
24
+
25
+ Checks both global security requirements and operation-level security requirements
26
+ to ensure they only reference schemes defined in components.securitySchemes.
27
+ """
28
+
29
+ @property
30
+ def rule_id(self) -> str:
31
+ return "security-scheme-reference"
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return "Security Scheme Reference Validation"
36
+
37
+ def validate(self, spec_data: dict[str, Any]) -> list[ValidationIssue]:
38
+ issues: list[ValidationIssue] = []
39
+
40
+ # Get defined security schemes
41
+ components = spec_data.get("components", {})
42
+ security_schemes = components.get("securitySchemes", {})
43
+ defined_schemes: set[str] = (
44
+ set(security_schemes.keys()) if isinstance(security_schemes, dict) else set()
45
+ )
46
+
47
+ # Check global security requirements
48
+ issues.extend(self._check_global_security(spec_data, defined_schemes))
49
+
50
+ # Check operation-level security requirements
51
+ issues.extend(self._check_operation_security(spec_data, defined_schemes))
52
+
53
+ return issues
54
+
55
+ @staticmethod
56
+ def _check_global_security(
57
+ spec_data: dict[str, Any], defined_schemes: set[str]
58
+ ) -> list[ValidationIssue]:
59
+ """Check global security requirements."""
60
+ issues: list[ValidationIssue] = []
61
+ global_security = spec_data.get("security", [])
62
+
63
+ if not isinstance(global_security, list):
64
+ return issues
65
+
66
+ for sec_req in global_security:
67
+ if not isinstance(sec_req, dict):
68
+ continue
69
+
70
+ for scheme in sec_req.keys():
71
+ if scheme not in defined_schemes:
72
+ issues.append(
73
+ ValidationIssue(
74
+ code="UNDEFINED_SECURITY_SCHEME_REFERENCE",
75
+ message=f"Global security requirement references undefined scheme '{scheme}'.",
76
+ severity=lsp.DiagnosticSeverity.Error,
77
+ path=["security"],
78
+ fixable=False,
79
+ )
80
+ )
81
+
82
+ return issues
83
+
84
+ @staticmethod
85
+ def _check_operation_security(
86
+ spec_data: dict[str, Any], defined_schemes: set[str]
87
+ ) -> list[ValidationIssue]:
88
+ """Check operation-level security requirements."""
89
+ issues: list[ValidationIssue] = []
90
+ paths = spec_data.get("paths", {})
91
+
92
+ if not isinstance(paths, dict):
93
+ return issues
94
+
95
+ for path_str, path_item in paths.items():
96
+ if not isinstance(path_item, dict):
97
+ continue
98
+
99
+ for method, operation in path_item.items():
100
+ if method not in _HTTP_METHODS:
101
+ continue
102
+
103
+ if not isinstance(operation, dict):
104
+ continue
105
+
106
+ op_security = operation.get("security", [])
107
+ if not isinstance(op_security, list):
108
+ continue
109
+
110
+ for sec_req in op_security:
111
+ if not isinstance(sec_req, dict):
112
+ continue
113
+
114
+ for scheme in sec_req.keys():
115
+ if scheme not in defined_schemes:
116
+ issues.append(
117
+ ValidationIssue(
118
+ code="UNDEFINED_SECURITY_SCHEME_REFERENCE",
119
+ message=f"Operation '{method.upper()}' at path '{path_str}' references undefined scheme '{scheme}'.",
120
+ severity=lsp.DiagnosticSeverity.Error,
121
+ path=[
122
+ "paths",
123
+ path_str,
124
+ method,
125
+ "security",
126
+ ],
127
+ fixable=False,
128
+ )
129
+ )
130
+
131
+ return issues
132
+
133
+
134
+ class UnusedSecuritySchemeRule(BaseRule):
135
+ """
136
+ Detects security schemes that are defined but never used.
137
+
138
+ This is a warning-level rule that helps identify potentially dead code
139
+ in the security scheme definitions.
140
+ """
141
+
142
+ @property
143
+ def rule_id(self) -> str:
144
+ return "unused-security-scheme"
145
+
146
+ @property
147
+ def name(self) -> str:
148
+ return "Unused Security Scheme Detection"
149
+
150
+ def validate(self, spec_data: dict[str, Any]) -> list[ValidationIssue]:
151
+ issues: list[ValidationIssue] = []
152
+
153
+ # Get defined security schemes
154
+ components = spec_data.get("components", {})
155
+ security_schemes = components.get("securitySchemes", {})
156
+ defined_schemes: set[str] = (
157
+ set(security_schemes.keys()) if isinstance(security_schemes, dict) else set()
158
+ )
159
+
160
+ # Collect all referenced schemes
161
+ referenced_schemes: set[str] = set()
162
+
163
+ # Check global security
164
+ global_security = spec_data.get("security", [])
165
+ if isinstance(global_security, list):
166
+ for sec_req in global_security:
167
+ if isinstance(sec_req, dict):
168
+ referenced_schemes.update(sec_req.keys())
169
+
170
+ # Check operation-level security
171
+ paths = spec_data.get("paths", {})
172
+ if isinstance(paths, dict):
173
+ for path_item in paths.values():
174
+ if not isinstance(path_item, dict):
175
+ continue
176
+
177
+ for method, operation in path_item.items():
178
+ if method not in _HTTP_METHODS or not isinstance(operation, dict):
179
+ continue
180
+
181
+ op_security = operation.get("security", [])
182
+ if isinstance(op_security, list):
183
+ for sec_req in op_security:
184
+ if isinstance(sec_req, dict):
185
+ referenced_schemes.update(sec_req.keys())
186
+
187
+ # Find unused schemes
188
+ unused = defined_schemes - referenced_schemes
189
+ for scheme in unused:
190
+ issues.append(
191
+ ValidationIssue(
192
+ code="UNUSED_SECURITY_SCHEME_DEFINED",
193
+ message=f"Security scheme '{scheme}' is defined in components.securitySchemes but not used in any security requirement.",
194
+ severity=lsp.DiagnosticSeverity.Warning,
195
+ path=["components", "securitySchemes", scheme],
196
+ fixable=True,
197
+ )
198
+ )
199
+
200
+ return issues