stackConsult commited on
Commit
40a5e4c
·
verified ·
1 Parent(s): bf1af21

Add schema registry with mapping, validation, and audit capabilities

Browse files
Files changed (1) hide show
  1. schema_registry.py +330 -0
schema_registry.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schema Registry - Mapping/Audit Utility
2
+
3
+ Provides schema registration, validation, and mapping capabilities
4
+ with audit trail and versioning support.
5
+ """
6
+
7
+ import json
8
+ import csv
9
+ from typing import Dict, List, Any, Optional, Tuple
10
+ from datetime import datetime
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ # Configure logging
15
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class SchemaRegistry:
20
+ """Manages schema definitions, mappings, and validation."""
21
+
22
+ def __init__(self, registry_path: str = "schema_registry.json", log_path: str = "schema_audit.log"):
23
+ self.registry_path = Path(registry_path)
24
+ self.log_path = Path(log_path)
25
+ self.schemas: Dict[str, Dict[str, Any]] = {}
26
+ self.mappings: Dict[str, Dict[str, str]] = {}
27
+ self.version = "1.0.0"
28
+ self._load_registry()
29
+
30
+ def _load_registry(self) -> None:
31
+ """Load existing schema registry from file."""
32
+ if self.registry_path.exists():
33
+ try:
34
+ with open(self.registry_path, 'r') as f:
35
+ data = json.load(f)
36
+ self.schemas = data.get('schemas', {})
37
+ self.mappings = data.get('mappings', {})
38
+ self.version = data.get('version', '1.0.0')
39
+ logger.info(f"Loaded registry with {len(self.schemas)} schemas")
40
+ except Exception as e:
41
+ logger.error(f"Failed to load registry: {e}")
42
+ self._audit_log("ERROR", f"Registry load failed: {e}")
43
+ else:
44
+ logger.info("No existing registry found, starting fresh")
45
+
46
+ def _save_registry(self) -> None:
47
+ """Save schema registry to file."""
48
+ try:
49
+ data = {
50
+ 'version': self.version,
51
+ 'schemas': self.schemas,
52
+ 'mappings': self.mappings,
53
+ 'last_updated': datetime.now().isoformat()
54
+ }
55
+ with open(self.registry_path, 'w') as f:
56
+ json.dump(data, f, indent=2)
57
+ logger.info("Registry saved successfully")
58
+ except Exception as e:
59
+ logger.error(f"Failed to save registry: {e}")
60
+ self._audit_log("ERROR", f"Registry save failed: {e}")
61
+
62
+ def _audit_log(self, level: str, message: str, metadata: Optional[Dict] = None) -> None:
63
+ """Write audit log entry."""
64
+ timestamp = datetime.now().isoformat()
65
+ log_entry = {
66
+ 'timestamp': timestamp,
67
+ 'level': level,
68
+ 'message': message,
69
+ 'metadata': metadata or {}
70
+ }
71
+
72
+ try:
73
+ with open(self.log_path, 'a') as f:
74
+ f.write(json.dumps(log_entry) + '\n')
75
+ except Exception as e:
76
+ logger.error(f"Failed to write audit log: {e}")
77
+
78
+ def register_schema(self, name: str, schema: Dict[str, Any], description: str = "") -> bool:
79
+ """Register a new schema definition.
80
+
81
+ Args:
82
+ name: Unique schema identifier
83
+ schema: Schema definition with field types and constraints
84
+ description: Human-readable schema description
85
+
86
+ Returns:
87
+ True if successful, False otherwise
88
+ """
89
+ try:
90
+ if name in self.schemas:
91
+ logger.warning(f"Schema '{name}' already exists, will be overwritten")
92
+
93
+ self.schemas[name] = {
94
+ 'schema': schema,
95
+ 'description': description,
96
+ 'created_at': datetime.now().isoformat(),
97
+ 'version': self.version
98
+ }
99
+
100
+ self._save_registry()
101
+ self._audit_log("INFO", f"Registered schema: {name}", {'schema_name': name})
102
+ return True
103
+
104
+ except Exception as e:
105
+ logger.error(f"Failed to register schema '{name}': {e}")
106
+ self._audit_log("ERROR", f"Schema registration failed: {name}", {'error': str(e)})
107
+ return False
108
+
109
+ def register_mapping(self, source_schema: str, target_schema: str,
110
+ field_mappings: Dict[str, str]) -> bool:
111
+ """Register field mappings between two schemas.
112
+
113
+ Args:
114
+ source_schema: Source schema name
115
+ target_schema: Target schema name
116
+ field_mappings: Dict mapping source fields to target fields
117
+
118
+ Returns:
119
+ True if successful, False otherwise
120
+ """
121
+ try:
122
+ mapping_key = f"{source_schema}->{target_schema}"
123
+
124
+ # Validate schemas exist
125
+ if source_schema not in self.schemas:
126
+ raise ValueError(f"Source schema '{source_schema}' not found")
127
+ if target_schema not in self.schemas:
128
+ raise ValueError(f"Target schema '{target_schema}' not found")
129
+
130
+ # Validate field mappings
131
+ source_fields = self.schemas[source_schema]['schema'].keys()
132
+ target_fields = self.schemas[target_schema]['schema'].keys()
133
+
134
+ for src_field, tgt_field in field_mappings.items():
135
+ if src_field not in source_fields:
136
+ raise ValueError(f"Source field '{src_field}' not in schema '{source_schema}'")
137
+ if tgt_field not in target_fields:
138
+ raise ValueError(f"Target field '{tgt_field}' not in schema '{target_schema}'")
139
+
140
+ self.mappings[mapping_key] = {
141
+ 'field_mappings': field_mappings,
142
+ 'created_at': datetime.now().isoformat()
143
+ }
144
+
145
+ self._save_registry()
146
+ self._audit_log("INFO", f"Registered mapping: {mapping_key}",
147
+ {'source': source_schema, 'target': target_schema})
148
+ return True
149
+
150
+ except Exception as e:
151
+ logger.error(f"Failed to register mapping: {e}")
152
+ self._audit_log("ERROR", f"Mapping registration failed", {'error': str(e)})
153
+ return False
154
+
155
+ def validate_data(self, schema_name: str, data: Dict[str, Any]) -> Tuple[bool, List[str]]:
156
+ """Validate data against a registered schema.
157
+
158
+ Args:
159
+ schema_name: Name of schema to validate against
160
+ data: Data to validate
161
+
162
+ Returns:
163
+ Tuple of (is_valid, list of error messages)
164
+ """
165
+ errors = []
166
+
167
+ try:
168
+ if schema_name not in self.schemas:
169
+ return False, [f"Schema '{schema_name}' not found"]
170
+
171
+ schema = self.schemas[schema_name]['schema']
172
+
173
+ # Check required fields
174
+ for field, field_def in schema.items():
175
+ if field_def.get('required', False) and field not in data:
176
+ errors.append(f"Required field '{field}' is missing")
177
+
178
+ # Type validation if field exists
179
+ if field in data:
180
+ expected_type = field_def.get('type')
181
+ actual_value = data[field]
182
+
183
+ if expected_type and not self._check_type(actual_value, expected_type):
184
+ errors.append(f"Field '{field}' has wrong type. Expected {expected_type}, got {type(actual_value).__name__}")
185
+
186
+ # Additional constraints
187
+ if 'min_length' in field_def and isinstance(actual_value, str):
188
+ if len(actual_value) < field_def['min_length']:
189
+ errors.append(f"Field '{field}' is too short (min: {field_def['min_length']})")
190
+
191
+ if 'max_length' in field_def and isinstance(actual_value, str):
192
+ if len(actual_value) > field_def['max_length']:
193
+ errors.append(f"Field '{field}' is too long (max: {field_def['max_length']})")
194
+
195
+ is_valid = len(errors) == 0
196
+ self._audit_log("INFO", f"Validation {'passed' if is_valid else 'failed'} for schema: {schema_name}",
197
+ {'schema': schema_name, 'valid': is_valid, 'error_count': len(errors)})
198
+
199
+ return is_valid, errors
200
+
201
+ except Exception as e:
202
+ error_msg = f"Validation error: {str(e)}"
203
+ logger.error(error_msg)
204
+ return False, [error_msg]
205
+
206
+ def _check_type(self, value: Any, expected_type: str) -> bool:
207
+ """Check if value matches expected type."""
208
+ type_map = {
209
+ 'string': str,
210
+ 'int': int,
211
+ 'float': float,
212
+ 'bool': bool,
213
+ 'list': list,
214
+ 'dict': dict,
215
+ 'any': object
216
+ }
217
+
218
+ expected_class = type_map.get(expected_type.lower())
219
+ if expected_class:
220
+ return isinstance(value, expected_class)
221
+ return True
222
+
223
+ def transform_data(self, data: Dict[str, Any], source_schema: str,
224
+ target_schema: str) -> Tuple[Optional[Dict[str, Any]], List[str]]:
225
+ """Transform data from source schema to target schema using registered mappings.
226
+
227
+ Args:
228
+ data: Source data
229
+ source_schema: Source schema name
230
+ target_schema: Target schema name
231
+
232
+ Returns:
233
+ Tuple of (transformed_data or None, list of errors)
234
+ """
235
+ errors = []
236
+
237
+ try:
238
+ # Validate source data
239
+ is_valid, validation_errors = self.validate_data(source_schema, data)
240
+ if not is_valid:
241
+ return None, validation_errors
242
+
243
+ # Get mapping
244
+ mapping_key = f"{source_schema}->{target_schema}"
245
+ if mapping_key not in self.mappings:
246
+ return None, [f"No mapping found for {mapping_key}"]
247
+
248
+ field_mappings = self.mappings[mapping_key]['field_mappings']
249
+
250
+ # Transform data
251
+ transformed = {}
252
+ for src_field, tgt_field in field_mappings.items():
253
+ if src_field in data:
254
+ transformed[tgt_field] = data[src_field]
255
+
256
+ # Validate transformed data
257
+ is_valid, validation_errors = self.validate_data(target_schema, transformed)
258
+ if not is_valid:
259
+ return None, validation_errors
260
+
261
+ self._audit_log("INFO", f"Data transformation successful: {mapping_key}",
262
+ {'source': source_schema, 'target': target_schema})
263
+
264
+ return transformed, []
265
+
266
+ except Exception as e:
267
+ error_msg = f"Transformation error: {str(e)}"
268
+ logger.error(error_msg)
269
+ self._audit_log("ERROR", "Data transformation failed", {'error': str(e)})
270
+ return None, [error_msg]
271
+
272
+ def get_schema(self, name: str) -> Optional[Dict[str, Any]]:
273
+ """Retrieve a registered schema."""
274
+ return self.schemas.get(name)
275
+
276
+ def list_schemas(self) -> List[str]:
277
+ """List all registered schema names."""
278
+ return list(self.schemas.keys())
279
+
280
+ def list_mappings(self) -> List[str]:
281
+ """List all registered mappings."""
282
+ return list(self.mappings.keys())
283
+
284
+ def export_to_json(self, filepath: str) -> bool:
285
+ """Export registry to JSON file."""
286
+ try:
287
+ data = {
288
+ 'version': self.version,
289
+ 'schemas': self.schemas,
290
+ 'mappings': self.mappings,
291
+ 'exported_at': datetime.now().isoformat()
292
+ }
293
+ with open(filepath, 'w') as f:
294
+ json.dump(data, f, indent=2)
295
+ logger.info(f"Registry exported to {filepath}")
296
+ return True
297
+ except Exception as e:
298
+ logger.error(f"Export failed: {e}")
299
+ return False
300
+
301
+ def import_from_json(self, filepath: str) -> bool:
302
+ """Import registry from JSON file."""
303
+ try:
304
+ with open(filepath, 'r') as f:
305
+ data = json.load(f)
306
+ self.schemas.update(data.get('schemas', {}))
307
+ self.mappings.update(data.get('mappings', {}))
308
+ self._save_registry()
309
+ logger.info(f"Registry imported from {filepath}")
310
+ return True
311
+ except Exception as e:
312
+ logger.error(f"Import failed: {e}")
313
+ return False
314
+
315
+
316
+ if __name__ == "__main__":
317
+ # Example usage
318
+ registry = SchemaRegistry()
319
+
320
+ # Register example schemas
321
+ user_schema = {
322
+ 'id': {'type': 'int', 'required': True},
323
+ 'username': {'type': 'string', 'required': True, 'min_length': 3},
324
+ 'email': {'type': 'string', 'required': True},
325
+ 'age': {'type': 'int', 'required': False}
326
+ }
327
+
328
+ registry.register_schema('user', user_schema, 'User profile schema')
329
+
330
+ print(f"Registered schemas: {registry.list_schemas()}")