Coverage for postrfp/ref/handlers/editors.py: 97%
142 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
1"""
2HTTP endpoints for managing Content objects
3"""
5from typing import Optional
7from sqlalchemy.orm import Session, joinedload
9from postrfp.shared.pager import Pager
10from postrfp.authorisation import perms
11from postrfp.shared.decorators import http
12from postrfp.model import User
13from postrfp.model.ref import Content, ContentSpec, ContentSpecMap, ContentQElementPair
14from postrfp.shared.serial.refmodels import (
15 ContentDocument,
16 ListResponse,
17 TagSummary,
18 SubjectSummary,
19 ContentSpecMapDocument,
20 ContentQElementPairDocument,
21 SchemaPointersResponse,
22)
23from postrfp.ref.service.content_service import (
24 update_content,
25 create_content,
26 fetch_content_spec,
27)
28from postrfp.ref.service.reference_validator import ContentReferenceValidator
29from postrfp.ref.permissions import check_content_authorization
30from postrfp.shared.exceptions import AuthorizationFailure
31from postrfp.model.exc import ValidationFailure
34def build_saved_content(content: Content) -> ContentDocument:
35 """
36 Build a ContentDocument response from a Content model instance.
37 Converts Tag and Subject relationships to their summary representations.
38 """
39 # Convert tags to TagSummary objects
40 tags = [
41 TagSummary(id=tag.id, name=tag.name, description=tag.description)
42 for tag in content.tags
43 ]
45 # Convert subjects to SubjectSummary objects
46 subjects = [
47 SubjectSummary(
48 id=subject.id,
49 name=subject.name,
50 code=subject.code,
51 description=subject.description,
52 subject_type=subject.subject_type.value,
53 )
54 for subject in content.subjects
55 ]
57 return ContentDocument(
58 id=content.id,
59 date_created=content.date_created,
60 date_updated=content.date_updated,
61 auth_policy=content.auth_policy,
62 title=content.title,
63 content_doc=content.content_doc,
64 schema_id=content.schema_id,
65 primary_subject_id=content.primary_subject_id,
66 tags=tags,
67 subjects=subjects,
68 author_org_id=content.author_org_id,
69 last_updated_by_id=content.last_updated_by_id,
70 )
73@http
74def get_contents(
75 session: Session,
76 user: User,
77 q_name: Optional[str] = None,
78 q_spec_id: Optional[int] = None,
79 pager: Optional[Pager] = None,
80) -> ListResponse:
81 """
82 List all content items accessible to the user's organization
84 Returns content items that the user's organization has permission to view,
85 including authored content, public content, and content with explicit permissions.
86 Results can be filtered by title and schema, and are paginated for performance.
88 **Query Parameters:**
89 - `q_name`: Filter content by title (case-insensitive partial match)
90 - `q_spec_id`: Filter by content schema/specification ID
91 - Standard pagination parameters supported
93 **Returns:** Paginated list of content summaries accessible to the user
96 **Content Summary includes:**
97 - Basic identification (ID, title, dates)
98 - Schema information
99 - Author organization
100 - Associated tags and subjects
102 **Examples:**
103 - Get all content: no query parameters
104 - Search by title: `?q_name=financial report`
105 - Filter by schema: `?q_spec_id=5`
106 """
107 if pager is None:
108 pager = Pager(page=1, page_size=20)
110 # Get all content that might be accessible, then filter by authorization
111 query = session.query(Content).order_by(Content.date_updated.desc())
113 if q_name:
114 query = query.filter(Content.title.ilike(f"%{q_name}%"))
116 if q_spec_id:
117 query = query.filter(Content.schema_id == q_spec_id)
119 # Get all content first, then filter by authorization
120 all_content = query.all()
122 # Filter content based on authorization
123 authorized_content = []
124 for content in all_content:
125 authorization_result = check_content_authorization(content, user, "view")
126 if authorization_result.granted:
127 authorized_content.append(content)
129 # Apply pagination to the authorized results
130 total_records = len(authorized_content)
131 start_idx = pager.startfrom
132 end_idx = min(pager.goto, total_records)
133 records = authorized_content[start_idx:end_idx]
135 return ListResponse(
136 items=[ContentDocument.model_validate(content) for content in records],
137 pagination=pager.as_pagination(total_records, len(records)),
138 )
141@http
142def get_content(session: Session, user: User, content_id: int) -> ContentDocument:
143 """
144 Get a single content item by ID
146 Retrieves complete information about a content item including all structured
147 data, metadata, relationships, and associated tags and subjects. The user's
148 organization must have appropriate permissions to view this content.
150 **Path Parameters:**
151 - `content_id`: Unique identifier of the content item
153 **Raises:**
154 - `NoResultFound`: If the content ID does not exist
155 - `AuthorizationFailure`: If the user lacks permission to view this content
156 """
157 content = session.get_one(Content, content_id)
159 # Enhanced permission check with detailed error reporting
160 authorization_result = check_content_authorization(content, user, "view")
161 if not authorization_result.granted:
162 raise AuthorizationFailure(
163 f"Cannot view Content {content_id}. {authorization_result.reason}. "
164 "You do not have permission to perform the requested action."
165 )
167 return build_saved_content(content)
170@http
171def post_content(
172 session: Session, user: User, content_doc: ContentDocument
173) -> ContentDocument:
174 """
175 Create a new content item
177 Creates a new structured content item validated against its schema. The content
178 is authored by the user's organization and can be associated with subjects,
179 tags, and custom permission settings.
181 **Returns:** Complete created content with assigned ID and metadata
183 **Schema Validation:**
184 The `content_doc` structure must conform to the JSON schema defined by `schema_id`.
185 Validation errors will be returned if the content doesn't match the schema.
189 **@permissions REF_CONTENT_SAVE**
190 """
191 user.check_permission(perms.REF_CONTENT_SAVE)
193 # Additional authorization checks could be added here for:
194 # - Schema access permissions
195 # - Subject association permissions
196 # - Organization content creation policies
197 # For now, REF_CONTENT_SAVE permission is sufficient
199 # Use the service function to create the content
200 new_content = create_content(
201 session, content_doc, author_org_id=user.org_id, created_by_id=user.id
202 )
204 session.flush()
206 return build_saved_content(new_content)
209@http
210def put_content(
211 session: Session, user: User, content_id: int, content_doc: ContentDocument
212) -> ContentDocument:
213 """
214 Update an existing content item
216 Modifies an existing content item with new data. The user's organization must
217 have edit permissions for this content. All changes are tracked in the revision
218 history for auditing purposes.
220 **Path Parameters:**
221 - `content_id`: ID of the content item to update
224 **Returns:** Updated content with new timestamps and metadata
227 **Schema Validation:**
228 Updated content must still conform to the original schema. Schema changes
229 require updating the `schema_id` if needed.
231 **Revision Tracking:**
232 Changes are automatically tracked in the revision history, including:
233 - What fields changed
234 - Who made the changes
235 - When changes occurred
236 - Optional change comments
238 **@permissions REF_CONTENT_SAVE**
240 **Raises:**
241 - `NoResultFound`: If the content ID does not exist
242 - `AuthorizationFailure`: If the user lacks edit permission
243 """
244 user.check_permission(perms.REF_CONTENT_SAVE)
246 content = session.get_one(Content, content_id)
248 # auth_policy is NOT checked here; only edit permission is required
250 # Use the service function to update the content
251 update_content(session, content, content_doc, user.id)
253 return build_saved_content(content)
256@http
257def delete_content(session: Session, user: User, content_id: int) -> None:
258 """
259 Delete a content item
261 Permanently removes a content item and all its associated data including
262 relationships, permissions, and revision history. This action cannot be undone.
263 The user's organization must have edit permissions for this content.
265 **Path Parameters:**
266 - `content_id`: ID of the content item to delete
268 **Returns:** None (204 No Content status)
270 **What Gets Deleted:**
271 - The content item itself
272 - All tag associations
273 - All subject associations
274 - All content relationships (both incoming and outgoing)
275 - Complete revision history
276 - Any comments or annotations
279 **Important Warnings:**
280 - **This action is irreversible** - deleted content cannot be recovered
281 - Other content items that reference this content will have broken relationships
282 - Organizations that had access will lose that access immediately
283 - Any workflows or external systems referencing this content may break
285 **@permissions REF_CONTENT_SAVE**
287 **Raises:**
288 - `NoResultFound`: If the content ID does not exist
289 - `AuthorizationFailure`: If the user lacks edit permission
290 """
291 user.check_permission(perms.REF_CONTENT_SAVE)
293 content = session.get_one(Content, content_id)
295 # Check authorization to delete this content
296 authorization_result = check_content_authorization(content, user, "edit")
297 if not authorization_result.granted:
298 raise AuthorizationFailure(
299 f"Cannot delete Content {content_id}. {authorization_result.reason}. "
300 "You do not have permission to perform the requested action."
301 )
303 session.delete(content)
306# ============================================================================
307# ContentSpecMap Endpoints - Managing Question-to-Content Field Mappings
308# ============================================================================
311def build_content_spec_map_document(
312 content_map: ContentSpecMap,
313) -> ContentSpecMapDocument:
314 """
315 Build a ContentSpecMapDocument response from a ContentSpecMap model instance.
316 Includes all nested ContentQElementPair objects.
317 """
318 pairs = [
319 ContentQElementPairDocument(
320 id=pair.id,
321 question_element_id=pair.question_element_id,
322 content_reference=pair.content_reference,
323 content_map_id=pair.content_map_id,
324 )
325 for pair in content_map.pairs
326 ]
328 return ContentSpecMapDocument(
329 id=content_map.id,
330 name=content_map.name,
331 description=content_map.description,
332 content_spec_id=content_map.content_spec_id,
333 content_spec_name=content_map.content_spec.name,
334 pairs=pairs,
335 )
338def _require_same_org(
339 content_spec: ContentSpec, user: User, action: str, entity_id: Optional[int] = None
340) -> None:
341 """
342 Check that a ContentSpec belongs to the user's organization.
344 Raises AuthorizationFailure if the ContentSpec belongs to a different organization.
346 Args:
347 content_spec: The ContentSpec to check
348 user: The user making the request
349 action: Description of the action being attempted (e.g., "access ContentSpecMap", "create ContentSpecMap")
350 entity_id: Optional ID to include in error message (e.g., content_map_id)
351 """
352 if content_spec.org_id != user.org_id:
353 entity_ref = f" {entity_id}" if entity_id else ""
354 raise AuthorizationFailure(
355 f"Cannot {action}{entity_ref}. "
356 "This ContentSpec belongs to another organization."
357 )
360@http
361def get_content_maps(
362 session: Session,
363 user: User,
364 q_name: Optional[str] = None,
365 q_spec_id: Optional[int] = None,
366 pager: Optional[Pager] = None,
367) -> ListResponse:
368 """
369 List all content maps accessible to the user's organization
371 Content maps define how questionnaire responses populate structured content.
372 Each map connects question elements to specific fields in a content document
373 using JSON Pointer expressions. Maps are filtered to show only those belonging
374 to content schemas owned by the user's organization.
376 **Query Parameters:**
377 - `q_name`: Filter maps by name (case-insensitive partial match)
378 - `q_spec_id`: Filter by content schema/specification ID
379 - Standard pagination parameters supported
381 **Returns:** Paginated list of content maps accessible to the user
383 **Access Control:**
384 Maps are filtered to show only:
385 - Maps associated with ContentSpecs owned by the user's organization
386 - This ensures organizations only see mappings for their own content structures
388 **Map Information Includes:**
389 - Map identification (ID, name, description)
390 - Associated ContentSpec ID
391 - List of all question-to-field mapping pairs
392 - JSON Pointer expressions for each field mapping
394 **Use Cases:**
395 - Browse available content population mappings
396 - Find mappings for specific content types
397 - Understand how questionnaires populate content
398 - Manage organization's content integration workflows
400 **Examples:**
401 - Get all maps: no query parameters
402 - Search by name: `?q_name=vendor SLA`
403 - Filter by schema: `?q_spec_id=5`
404 """
405 if pager is None:
406 pager = Pager(page=1, page_size=20)
408 # Filter maps by user's org through ContentSpec relationship
409 query = (
410 session.query(ContentSpecMap)
411 .join(ContentSpec, ContentSpecMap.content_spec_id == ContentSpec.id)
412 .options(joinedload(ContentSpecMap.content_spec).load_only(ContentSpec.name))
413 .filter(ContentSpec.org_id == user.org_id)
414 .order_by(ContentSpecMap.name)
415 )
417 if q_name:
418 query = query.filter(ContentSpecMap.name.ilike(f"%{q_name}%"))
420 if q_spec_id:
421 query = query.filter(ContentSpecMap.content_spec_id == q_spec_id)
423 total_records = query.count()
424 records = query.slice(pager.startfrom, pager.goto).all()
426 return ListResponse(
427 items=[build_content_spec_map_document(map_obj) for map_obj in records],
428 pagination=pager.as_pagination(total_records, len(records)),
429 )
432@http
433def get_content_map(
434 session: Session, user: User, content_map_id: int
435) -> ContentSpecMapDocument:
436 """
437 Get a single content map by ID
439 Retrieves complete information about a content map including all question-to-field
440 mapping pairs. Each pair specifies which question element maps to which field in
441 the content document via a JSON Pointer expression.
443 **Path Parameters:**
444 - `content_map_id`: Unique identifier of the content map
446 **JSON Pointer Expression Examples:**
447 - `$.sla.uptime` → Maps to content_doc["sla"]["uptime"]
448 - `$.contact.email` → Maps to content_doc["contact"]["email"]
449 - `$.features[0].name` → Maps to content_doc["features"][0]["name"]
451 **Use Cases:**
452 - Understand complete mapping structure
453 - Review question-to-field connections
454 - Debug content population issues
455 - Document integration workflows
457 **Raises:**
458 - `NoResultFound`: If the content map ID does not exist
459 - `AuthorizationFailure`: If the map belongs to another organization's ContentSpec
460 """
461 content_map = session.get_one(ContentSpecMap, content_map_id)
463 # Check that the map belongs to a ContentSpec owned by user's org
464 _require_same_org(
465 content_map.content_spec, user, "access ContentSpecMap", content_map_id
466 )
468 return build_content_spec_map_document(content_map)
471@http
472def post_content_map(
473 session: Session, user: User, map_doc: ContentSpecMapDocument
474) -> ContentSpecMapDocument:
475 """
476 Create a new content map with mapping pairs
478 Creates a new mapping between questionnaire elements and content fields.
479 The map can include multiple pairs in a single request, defining how various
480 question responses populate different fields in the content document.
482 **Pair Structure:**
483 Each pair in the `pairs` array must include:
484 - `question_element_id`: ID of the question element
485 - `content_reference`: JSONPointer expression for the content field
487 **Returns:** Created map with all pairs, assigned IDs, and complete structure
489 **JSON Pointer Requirements:**
490 - Must be valid JSON Pointer expressions starting with `$`
491 - Should reference fields that exist in the associated ContentSpec
492 - Can target nested objects, arrays, and complex structures
493 - Examples: `$.name`, `$.address.city`, `$.items[0].quantity`
495 **Validation:**
496 The system validates:
497 - ContentSpec ID exists and belongs to user's organization
498 - Question element IDs exist (if provided)
499 - JSON Pointer expressions are well-formed
500 - No duplicate question elements in the same map
502 **Ownership:**
503 - Map is associated with the ContentSpec's organization
504 - Only users from the ContentSpec's organization can create/edit maps
506 **@permissions REF_CONTENT_SAVE**
508 **Example:**
509 ```json
510 {
511 "name": "Vendor SLA Mapping",
512 "description": "Maps vendor questionnaire to SLA content",
513 "content_spec_id": 5,
514 "pairs": [
515 {
516 "question_element_id": 123,
517 "content_reference": "$.sla.uptime_guarantee"
518 },
519 {
520 "question_element_id": 124,
521 "content_reference": "$.sla.response_time"
522 }
523 ]
524 }
525 ```
527 **Raises:**
528 - `NoResultFound`: If the ContentSpec ID does not exist
529 - `AuthorizationFailure`: If the ContentSpec belongs to another organization
530 - `ValidationFailure`: If content_references are invalid against the schema
531 """
532 user.check_permission(perms.REF_CONTENT_SAVE)
534 # Verify ContentSpec exists and belongs to user's org
535 content_spec = session.get_one(ContentSpec, map_doc.content_spec_id)
536 _require_same_org(content_spec, user, "create ContentSpecMap for ContentSpec")
538 # Validate all content_references against the schema
539 if map_doc.pairs:
540 validator = ContentReferenceValidator(content_spec)
541 is_valid, error_messages = validator.validate_pairs(map_doc.pairs)
543 if not is_valid:
544 raise ValidationFailure(
545 "Invalid content references in mapping pairs", error_messages
546 )
548 # Create the content map
549 new_map = ContentSpecMap(
550 name=map_doc.name,
551 description=map_doc.description,
552 content_spec_id=map_doc.content_spec_id,
553 )
554 session.add(new_map)
555 session.flush() # Get the map ID for the pairs
557 # Create the pairs
558 for pair_doc in map_doc.pairs:
559 pair = ContentQElementPair(
560 content_map_id=new_map.id,
561 question_element_id=pair_doc.question_element_id,
562 content_reference=pair_doc.content_reference,
563 )
564 session.add(pair)
566 session.flush()
567 return build_content_spec_map_document(new_map)
570@http
571def put_content_map(
572 session: Session, user: User, content_map_id: int, map_doc: ContentSpecMapDocument
573) -> ContentSpecMapDocument:
574 """
575 Update an existing content map and replace all mapping pairs
577 Modifies an existing content map with new data. This operation replaces ALL
578 existing mapping pairs with the new set provided in the request. Any pairs
579 not included in the update will be deleted.
581 **Path Parameters:**
582 - `content_map_id`: ID of the content map to update
584 **Returns:** Updated map with new structure and all pairs
586 **Important - Pair Replacement Behavior:**
587 - ALL existing pairs are deleted
588 - ALL pairs in the request are created as new
589 - To keep a pair, include it in the update request
590 - To remove a pair, omit it from the pairs array
591 - To add a pair, include it in the pairs array
593 **Use Cases:**
594 - Rename or re-describe the mapping
595 - Add new question-to-field mappings
596 - Remove obsolete mappings
597 - Reorganize mapping structure
598 - Move map to a different ContentSpec
600 **Best Practices:**
601 - Include all pairs you want to keep in the update
602 - Review existing pairs before updating (use GET first)
603 - Test mapping changes with sample questionnaire data
604 - Document reasons for structural changes
606 **@permissions REF_CONTENT_SAVE**
608 **Raises:**
609 - `NoResultFound`: If the content map ID does not exist
610 - `AuthorizationFailure`: If the map or target ContentSpec belongs to another organization
611 - `ValidationFailure`: If content_references are invalid against the schema
613 **Example - Adding a new pair while keeping existing ones:**
614 ```json
615 {
616 "name": "Vendor SLA Mapping",
617 "description": "Updated mapping with additional field",
618 "content_spec_id": 5,
619 "pairs": [
620 {"question_element_id": 123, "content_reference": "$.sla.uptime_guarantee"},
621 {"question_element_id": 124, "content_reference": "$.sla.response_time"},
622 {"question_element_id": 125, "content_reference": "$.sla.support_hours"}
623 ]
624 }
625 ```
626 """
627 user.check_permission(perms.REF_CONTENT_SAVE)
629 content_map = session.get_one(ContentSpecMap, content_map_id)
631 # Check that the current map belongs to user's org
632 _require_same_org(
633 content_map.content_spec, user, "update ContentSpecMap", content_map_id
634 )
636 # If changing content_spec_id, verify the new one also belongs to user's org
637 target_content_spec = content_map.content_spec
638 if map_doc.content_spec_id != content_map.content_spec_id:
639 target_content_spec = fetch_content_spec(session, map_doc.content_spec_id)
640 _require_same_org(
641 target_content_spec, user, "move ContentSpecMap to ContentSpec"
642 )
644 # Validate all content_references against the (potentially new) schema
645 if map_doc.pairs:
646 validator = ContentReferenceValidator(target_content_spec)
647 is_valid, error_messages = validator.validate_pairs(map_doc.pairs)
649 if not is_valid:
650 raise ValidationFailure(
651 "Invalid content references in mapping pairs", error_messages
652 )
654 # Update basic fields
655 content_map.name = map_doc.name
656 content_map.description = map_doc.description
657 content_map.content_spec_id = map_doc.content_spec_id
659 # Delete all existing pairs
660 content_map.pairs.clear()
662 # Create new pairs
663 for pair_doc in map_doc.pairs:
664 pair = ContentQElementPair(
665 content_map_id=content_map.id,
666 question_element_id=pair_doc.question_element_id,
667 content_reference=pair_doc.content_reference,
668 )
669 content_map.pairs.append(pair)
671 session.flush()
672 return build_content_spec_map_document(content_map)
675@http
676def delete_content_map(session: Session, user: User, content_map_id: int) -> None:
677 """
678 Delete a content map and all its mapping pairs
680 Permanently removes a content map and all associated question-to-field mappings.
681 This action cannot be undone. The deletion only affects the mapping definition;
682 it does not affect existing content or questionnaires.
684 **Path Parameters:**
685 - `content_map_id`: ID of the content map to delete
687 **Returns:** None (204 No Content status)
689 **What Gets Deleted:**
690 - The content map itself
691 - All question-to-field mapping pairs
692 - All mapping metadata and documentation
694 **What Is NOT Affected:**
695 - Content items (existing content remains unchanged)
696 - ContentSpec (the schema remains intact)
697 - Question elements (questions remain in questionnaires)
698 - Questionnaire responses (answers are preserved)
700 **Impact:**
701 - Future questionnaire responses cannot use this mapping to populate content
702 - Existing content created via this mapping remains valid
703 - Manual content population is still possible
704 - Other mappings for the same ContentSpec continue to work
706 **Permission Requirements:**
707 - User must have REF_CONTENT_SAVE permission
708 - Map must belong to a ContentSpec owned by user's organization
710 **Use Cases:**
711 - Remove obsolete or incorrect mappings
712 - Clean up unused mapping definitions
713 - Reorganize mapping structure (delete then recreate)
715 **@permissions REF_CONTENT_SAVE**
717 **Raises:**
718 - `NoResultFound`: If the content map ID does not exist
719 - `AuthorizationFailure`: If the map belongs to another organization's ContentSpec
721 **Note:** This is a safe operation that only removes the mapping instructions.
722 No actual content or questionnaire data is deleted.
723 """
724 user.check_permission(perms.REF_CONTENT_SAVE)
726 content_map = session.get_one(ContentSpecMap, content_map_id)
728 # Check that the map belongs to user's org
729 _require_same_org(
730 content_map.content_spec, user, "delete ContentSpecMap", content_map_id
731 )
733 session.delete(content_map)
736@http
737def get_spec_pointers(
738 session: Session,
739 user: User,
740 spec_id: int,
741) -> SchemaPointersResponse:
742 """
743 Get all valid JSON Pointer paths for a ContentSpec's schema
745 Returns a comprehensive list of all valid JSON Pointer expressions that can be
746 used when creating ContentQElementPair mappings. This endpoint is designed to
747 help UI developers provide auto-complete, validation, or selection interfaces
748 when users are configuring question-to-field mappings.
750 **Path Parameters:**
751 - `spec_id`: The ID of the ContentSpec to get pointers for
754 **Access Control:**
755 - Requires read access to the ContentSpec (must belong to user's organization)
756 - This ensures organizations only access pointer information for their own schemas
758 **Pointer Extraction:**
759 - Recursively extracts all valid paths from the JSON Schema
760 - Handles nested objects, arrays, $ref definitions, and combinators
761 - Array indices are normalized (e.g., `/items/0` represents any array element)
762 - Paths are sorted alphabetically for consistent presentation
764 **Use Cases:**
765 - Populate dropdowns or auto-complete fields in mapping UIs
766 - Validate user input when creating/editing mappings
767 - Display available fields when configuring content population
768 - Document available fields for API consumers
770 **Examples:**
771 For a schema with company and financial data, returns paths like:
772 - `/company/name`
773 - `/company/legal_status/jurisdiction`
774 - `/financial/auditing/external_auditor`
775 - `/metadata/assessment_date`
777 **Raises:**
778 - `NoResultFound`: If the content_spec_id does not exist
779 - `AuthorizationFailure`: If the ContentSpec belongs to another organization
781 **Performance Notes:**
782 - Pointers are cached by the validator for efficiency
783 - Safe to call frequently in UI workflows
785 **Access:** Requires authentication; no explicit permission needed for read operations
786 """
787 # Fetch the content spec and verify ownership
788 content_spec = session.get_one(ContentSpec, spec_id)
789 _require_same_org(content_spec, user, "access ContentSpec", spec_id)
791 # Use the validator to extract all valid paths
792 validator = ContentReferenceValidator(content_spec)
793 pointers = sorted(validator.get_valid_paths())
795 return SchemaPointersResponse(
796 content_spec_id=content_spec.id,
797 content_spec_name=content_spec.name,
798 pointers=pointers,
799 )