Task 002: add shared domain contracts

This commit is contained in:
2026-05-21 17:45:00 +03:00
parent d42f0c4392
commit a0ae06adfa
31 changed files with 4043 additions and 27 deletions
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import NAMESPACE_URL, uuid5
from src.domain.contracts import (
ArticleCreateRequest,
ArticleCreateResponse,
ArticleSummary,
ArticleWorkflowStatus,
PublishingStatus,
)
PLACEHOLDER_CREATED_AT = datetime(1970, 1, 1, tzinfo=UTC)
def build_placeholder_article_response(
request: ArticleCreateRequest,
) -> ArticleCreateResponse:
article_id = uuid5(
NAMESPACE_URL,
f"article:{request.target_site_id}:{request.brief_description}",
)
article = ArticleSummary(
id=article_id,
target_site_id=request.target_site_id,
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
brief_description=request.brief_description,
working_title=request.working_title,
language=request.language,
content_type=request.content_type,
primary_keyword=request.primary_keyword,
assigned_editor_id=request.assigned_editor_id,
created_at=PLACEHOLDER_CREATED_AT,
updated_at=PLACEHOLDER_CREATED_AT,
)
return ArticleCreateResponse(article=article)
@@ -0,0 +1,87 @@
from .enums import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
)
from .models import (
AgentJobOutput,
AgentJobSummary,
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
ArticleSummary,
AssetSummary,
ClaimSummary,
DraftSummary,
EvidenceSummary,
PlanSectionSummary,
PlanSummary,
PublishCommitSummary,
PublishingRules,
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ReviewSummary,
RunnerFileRef,
ScriptConfigVersionSummary,
TargetSiteConfig,
UserSummary,
)
from .workflow import (
ARTICLE_WORKFLOW_STATUSES,
ARTICLE_WORKFLOW_TRANSITIONS,
next_workflow_statuses,
)
__all__ = [
"ARTICLE_WORKFLOW_STATUSES",
"ARTICLE_WORKFLOW_TRANSITIONS",
"AgentJobErrorCategory",
"AgentJobOutput",
"AgentJobStatus",
"AgentJobSummary",
"AgentJobType",
"ArticleCreateRequest",
"ArticleCreateResponse",
"ArticleDetailResponse",
"ArticleListResponse",
"ArticleSummary",
"ArticleWorkflowStatus",
"AssetStatus",
"AssetSummary",
"AssetType",
"ClaimRiskLevel",
"ClaimSummary",
"ClaimSupportStatus",
"DraftSummary",
"EvidenceSummary",
"PlanReviewStatus",
"PlanSectionSummary",
"PlanSummary",
"PublishCommitSummary",
"PublishingRules",
"PublishingStatus",
"ResearchArtifactManifestSummary",
"ResearchArtifactSummary",
"ReviewStatus",
"ReviewSummary",
"ReviewType",
"Role",
"RunnerFileRef",
"ScriptConfigVersionStatus",
"ScriptConfigVersionSummary",
"TargetSiteConfig",
"UserSummary",
"next_workflow_statuses",
]
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from enum import Enum
class Role(str, Enum):
ADMIN = "ADMIN"
EDITOR = "EDITOR"
class ArticleWorkflowStatus(str, Enum):
ARTICLE_BRIEF_CREATED = "ARTICLE_BRIEF_CREATED"
BOUNDARY_QUESTIONS_GENERATED = "BOUNDARY_QUESTIONS_GENERATED"
BOUNDARY_ANSWERS_SUBMITTED = "BOUNDARY_ANSWERS_SUBMITTED"
PLAN_GENERATED = "PLAN_GENERATED"
PLAN_REVIEW_REQUIRED = "PLAN_REVIEW_REQUIRED"
PLAN_REVISION_REQUIRED = "PLAN_REVISION_REQUIRED"
RESEARCH_RUNNING = "RESEARCH_RUNNING"
EVIDENCE_MATRIX_READY = "EVIDENCE_MATRIX_READY"
PARALLEL_PRODUCTION_RUNNING = "PARALLEL_PRODUCTION_RUNNING"
DRAFT_ASSEMBLED = "DRAFT_ASSEMBLED"
SEO_AND_LANGUAGE_REVIEW_READY = "SEO_AND_LANGUAGE_REVIEW_READY"
FINAL_REVIEW_REQUIRED = "FINAL_REVIEW_REQUIRED"
FINAL_REVISION_REQUIRED = "FINAL_REVISION_REQUIRED"
PUBLISH_DRY_RUN_REQUIRED = "PUBLISH_DRY_RUN_REQUIRED"
PUBLISH_COMMIT_READY = "PUBLISH_COMMIT_READY"
PUBLISH_COMMIT_CREATED = "PUBLISH_COMMIT_CREATED"
class AgentJobStatus(str, Enum):
QUEUED = "QUEUED"
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
class AgentJobType(str, Enum):
BOUNDARY_QUESTIONS = "BOUNDARY_QUESTIONS"
PLAN_GENERATION = "PLAN_GENERATION"
RESEARCH = "RESEARCH"
EVIDENCE_MATRIX = "EVIDENCE_MATRIX"
SECTION_SCAFFOLD = "SECTION_SCAFFOLD"
DRAFT_ASSEMBLY = "DRAFT_ASSEMBLY"
SEO_REVIEW = "SEO_REVIEW"
LANGUAGE_REVIEW = "LANGUAGE_REVIEW"
PUBLISH_DRY_RUN = "PUBLISH_DRY_RUN"
PUBLISH_COMMIT = "PUBLISH_COMMIT"
TEST_CODEX = "TEST_CODEX"
class AgentJobErrorCategory(str, Enum):
FAILED_SCHEMA_VALIDATION = "FAILED_SCHEMA_VALIDATION"
CLI_EXIT_CODE_FAILURE = "CLI_EXIT_CODE_FAILURE"
TIMEOUT = "TIMEOUT"
MISSING_OUTPUT_FILE = "MISSING_OUTPUT_FILE"
UNSUPPORTED_CLAIMS_FOUND = "UNSUPPORTED_CLAIMS_FOUND"
SOURCE_RETRIEVAL_FAILED = "SOURCE_RETRIEVAL_FAILED"
RESEARCH_ARTIFACT_UPLOAD_FAILED = "RESEARCH_ARTIFACT_UPLOAD_FAILED"
PUBLISH_DRY_RUN_FAILED = "PUBLISH_DRY_RUN_FAILED"
GIT_CHECKOUT_FAILED = "GIT_CHECKOUT_FAILED"
GIT_COMMIT_FAILED = "GIT_COMMIT_FAILED"
GIT_PUSH_NON_FAST_FORWARD = "GIT_PUSH_NON_FAST_FORWARD"
PUBLISH_VERIFICATION_FAILED = "PUBLISH_VERIFICATION_FAILED"
class ClaimSupportStatus(str, Enum):
SUPPORTED = "SUPPORTED"
UNSUPPORTED = "UNSUPPORTED"
NEEDS_REVIEW = "NEEDS_REVIEW"
class ClaimRiskLevel(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class PublishingStatus(str, Enum):
PUBLISH_NOT_STARTED = "PUBLISH_NOT_STARTED"
PUBLISH_DRY_RUN_REQUIRED = "PUBLISH_DRY_RUN_REQUIRED"
PUBLISH_DRY_RUN_RUNNING = "PUBLISH_DRY_RUN_RUNNING"
PUBLISH_DRY_RUN_FAILED = "PUBLISH_DRY_RUN_FAILED"
PUBLISH_COMMIT_READY = "PUBLISH_COMMIT_READY"
PUBLISH_COMMIT_CREATED = "PUBLISH_COMMIT_CREATED"
PUBLISH_VERIFICATION_FAILED = "PUBLISH_VERIFICATION_FAILED"
class AssetStatus(str, Enum):
PENDING = "PENDING"
GENERATED = "GENERATED"
APPROVED = "APPROVED"
REJECTED = "REJECTED"
class AssetType(str, Enum):
HERO_IMAGE = "hero_image"
DIAGRAM = "diagram"
TABLE = "table"
INLINE_IMAGE = "inline_image"
class PlanReviewStatus(str, Enum):
PENDING_REVIEW = "PENDING_REVIEW"
APPROVED = "APPROVED"
REVISION_REQUESTED = "REVISION_REQUESTED"
class ReviewStatus(str, Enum):
PENDING = "PENDING"
APPROVED = "APPROVED"
CHANGES_REQUESTED = "CHANGES_REQUESTED"
class ReviewType(str, Enum):
PLAN = "PLAN"
EVIDENCE = "EVIDENCE"
ASSET = "ASSET"
SEO_LANGUAGE = "SEO_LANGUAGE"
FINAL = "FINAL"
class ScriptConfigVersionStatus(str, Enum):
DRAFT = "DRAFT"
ACTIVE = "ACTIVE"
DEPRECATED = "DEPRECATED"
+266
View File
@@ -0,0 +1,266 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from .enums import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
)
JsonObject = dict[str, Any]
class ContractModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class UserSummary(ContractModel):
id: UUID
display_name: str = Field(min_length=1)
role: Role
class PublishingRules(ContractModel):
repository_url: str = Field(min_length=1)
production_branch: str = Field(min_length=1)
content_format: str = Field(default="mdx", min_length=1)
content_path_template: str = Field(min_length=1)
asset_path_template: str = Field(min_length=1)
frontmatter_mapping: JsonObject = Field(default_factory=dict)
transform_script_version_id: UUID | None = None
dry_run_renderer: str | None = None
class TargetSiteConfig(ContractModel):
id: UUID
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
publishing_type: str = Field(default="git_next", min_length=1)
default_language: str = Field(default="en", min_length=2)
brand_voice: str = Field(min_length=1)
audience: str = Field(min_length=1)
seo_rules: JsonObject = Field(default_factory=dict)
visual_rules: JsonObject = Field(default_factory=dict)
source_rules: JsonObject = Field(default_factory=dict)
publishing_rules: PublishingRules
active_script_config_version_id: UUID | None = None
created_at: datetime
updated_at: datetime
class ScriptConfigVersionSummary(ContractModel):
id: UUID
target_site_id: UUID
version: int = Field(ge=1)
status: ScriptConfigVersionStatus
publishing_yaml_hash: str = Field(min_length=1)
transform_script_hash: str = Field(min_length=1)
created_by: UUID
created_at: datetime
class ArticleCreateRequest(ContractModel):
target_site_id: UUID
brief_description: str = Field(min_length=1)
working_title: str | None = None
language: str = Field(default="en", min_length=2)
content_type: str = Field(default="longform_guide", min_length=1)
primary_keyword: str | None = None
assigned_editor_id: UUID | None = None
class ArticleSummary(ContractModel):
id: UUID
target_site_id: UUID
status: ArticleWorkflowStatus
publishing_status: PublishingStatus
brief_description: str
working_title: str | None = None
language: str
content_type: str
primary_keyword: str | None = None
assigned_editor_id: UUID | None = None
created_at: datetime
updated_at: datetime
class ArticleCreateResponse(ContractModel):
article: ArticleSummary
class ArticleListResponse(ContractModel):
articles: list[ArticleSummary]
class PlanSectionSummary(ContractModel):
id: UUID
article_plan_id: UUID
sort_order: int = Field(ge=1)
heading: str = Field(min_length=1)
purpose: str | None = None
claims_to_support: list[str] = Field(default_factory=list)
target_word_count: int | None = Field(default=None, ge=1)
class PlanSummary(ContractModel):
id: UUID
article_id: UUID
version: int = Field(ge=1)
status: PlanReviewStatus
recommended_title: str | None = None
search_intent: str | None = None
thesis: str | None = None
sections: list[PlanSectionSummary] = Field(default_factory=list)
claims_to_prove: list[str] = Field(default_factory=list)
risks: list[str] = Field(default_factory=list)
created_at: datetime
class ResearchArtifactSummary(ContractModel):
artifact_type: str = Field(min_length=1)
source_url: str = Field(min_length=1)
object_key: str = Field(min_length=1)
content_hash: str = Field(min_length=1)
metadata: JsonObject = Field(default_factory=dict)
class ResearchArtifactManifestSummary(ContractModel):
id: UUID
article_id: UUID
agent_job_id: UUID
s3_prefix: str = Field(min_length=1)
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
created_at: datetime
class EvidenceSummary(ContractModel):
id: UUID
article_id: UUID
source_title: str = Field(min_length=1)
source_url: str = Field(min_length=1)
source_type: str = Field(min_length=1)
source_quality_score: float = Field(ge=0, le=1)
summary: str = Field(min_length=1)
supports_claims: list[UUID] = Field(default_factory=list)
artifact_manifest_id: UUID
retrieved_at: datetime
class ClaimSummary(ContractModel):
id: UUID
article_id: UUID
section_id: UUID | None = None
claim_text: str = Field(min_length=1)
support_status: ClaimSupportStatus
risk_level: ClaimRiskLevel
evidence_item_ids: list[UUID] = Field(default_factory=list)
class DraftSummary(ContractModel):
id: UUID
article_id: UUID
version: int = Field(ge=1)
title: str = Field(min_length=1)
slug: str = Field(min_length=1)
meta_title: str | None = None
meta_description: str | None = None
body_object_key: str | None = None
status: ArticleWorkflowStatus
updated_at: datetime
class AssetSummary(ContractModel):
id: UUID
article_id: UUID
asset_type: AssetType
title: str = Field(min_length=1)
prompt: str | None = None
file_url: str | None = None
alt_text: str | None = None
caption: str | None = None
status: AssetStatus
class ReviewSummary(ContractModel):
id: UUID
article_id: UUID
review_type: ReviewType
status: ReviewStatus
reviewer_id: UUID | None = None
notes: str | None = None
created_at: datetime
class PublishCommitSummary(ContractModel):
id: UUID
article_id: UUID
target_site_id: UUID
repository_url: str = Field(min_length=1)
branch: str = Field(min_length=1)
commit_sha: str | None = None
content_bundle_manifest: JsonObject = Field(default_factory=dict)
status: PublishingStatus
deployment_status: str | None = None
created_at: datetime
class RunnerFileRef(ContractModel):
path: str = Field(min_length=1)
content_hash: str | None = None
class AgentJobSummary(ContractModel):
id: UUID
article_id: UUID
job_type: AgentJobType
agent_profile: str = Field(min_length=1)
status: AgentJobStatus
workspace_path: str | None = None
input_files: list[RunnerFileRef] = Field(default_factory=list)
output_files: list[RunnerFileRef] = Field(default_factory=list)
error_category: AgentJobErrorCategory | None = None
error_message: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
class AgentJobOutput(ContractModel):
status: AgentJobStatus
output_files: list[RunnerFileRef] = Field(default_factory=list)
error_category: AgentJobErrorCategory | None = None
error_message: str | None = None
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
payload: JsonObject = Field(default_factory=dict)
class ArticleDetailResponse(ContractModel):
article: ArticleSummary
target_site: TargetSiteConfig | None = None
plan: PlanSummary | None = None
draft: DraftSummary | None = None
evidence: list[EvidenceSummary] = Field(default_factory=list)
claims: list[ClaimSummary] = Field(default_factory=list)
assets: list[AssetSummary] = Field(default_factory=list)
reviews: list[ReviewSummary] = Field(default_factory=list)
agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
research_manifests: list[ResearchArtifactManifestSummary] = Field(
default_factory=list
)
publish_commit: PublishCommitSummary | None = None
@@ -0,0 +1,105 @@
from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, TypeAdapter
from .enums import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
)
from .models import (
AgentJobOutput,
AgentJobSummary,
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
ArticleSummary,
AssetSummary,
ClaimSummary,
DraftSummary,
EvidenceSummary,
PlanSectionSummary,
PlanSummary,
PublishCommitSummary,
PublishingRules,
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ReviewSummary,
RunnerFileRef,
ScriptConfigVersionSummary,
TargetSiteConfig,
UserSummary,
)
CONTRACT_ENUMS: tuple[type[Enum], ...] = (
Role,
ArticleWorkflowStatus,
AgentJobStatus,
AgentJobType,
AgentJobErrorCategory,
ClaimSupportStatus,
ClaimRiskLevel,
PublishingStatus,
AssetStatus,
AssetType,
PlanReviewStatus,
ReviewStatus,
ReviewType,
ScriptConfigVersionStatus,
)
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
UserSummary,
PublishingRules,
TargetSiteConfig,
ScriptConfigVersionSummary,
ArticleCreateRequest,
ArticleSummary,
ArticleCreateResponse,
ArticleListResponse,
PlanSectionSummary,
PlanSummary,
ResearchArtifactSummary,
ResearchArtifactManifestSummary,
EvidenceSummary,
ClaimSummary,
DraftSummary,
AssetSummary,
ReviewSummary,
PublishCommitSummary,
RunnerFileRef,
AgentJobSummary,
AgentJobOutput,
ArticleDetailResponse,
)
def inject_contract_schemas(openapi_schema: dict[str, Any]) -> None:
schemas = openapi_schema.setdefault("components", {}).setdefault("schemas", {})
for enum in CONTRACT_ENUMS:
schemas[enum.__name__] = TypeAdapter(enum).json_schema(
ref_template="#/components/schemas/{model}"
)
for model in CONTRACT_SCHEMA_MODELS:
schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
for name, definition in schema.pop("$defs", {}).items():
schemas.setdefault(name, definition)
schemas[model.__name__] = schema
@@ -0,0 +1,83 @@
from __future__ import annotations
from .enums import ArticleWorkflowStatus
ARTICLE_WORKFLOW_STATUSES: tuple[ArticleWorkflowStatus, ...] = (
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
ArticleWorkflowStatus.PLAN_GENERATED,
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
ArticleWorkflowStatus.RESEARCH_RUNNING,
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
)
ARTICLE_WORKFLOW_TRANSITIONS: dict[
ArticleWorkflowStatus, tuple[ArticleWorkflowStatus, ...]
] = {
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED: (
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
),
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED: (
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
),
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED: (
ArticleWorkflowStatus.PLAN_GENERATED,
),
ArticleWorkflowStatus.PLAN_GENERATED: (
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
),
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED: (
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
ArticleWorkflowStatus.RESEARCH_RUNNING,
),
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED: (
ArticleWorkflowStatus.PLAN_GENERATED,
),
ArticleWorkflowStatus.RESEARCH_RUNNING: (
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
),
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY: (
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
),
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING: (
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
),
ArticleWorkflowStatus.DRAFT_ASSEMBLED: (
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
),
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY: (
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
),
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED: (
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
),
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED: (
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
),
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED: (
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
),
ArticleWorkflowStatus.PUBLISH_COMMIT_READY: (
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
),
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED: (),
}
def next_workflow_statuses(
status: ArticleWorkflowStatus,
) -> tuple[ArticleWorkflowStatus, ...]:
return ARTICLE_WORKFLOW_TRANSITIONS[status]
+25 -1
View File
@@ -1,10 +1,15 @@
from typing import Any
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse
from src.infrastructure.dependencies import check_dependencies
from src.domain.contracts.openapi import inject_contract_schemas
from src.presentation.routes.articles import router as articles_router
app = FastAPI(title="AI Content Pipeline Backend")
app.include_router(articles_router)
@app.get("/health")
@@ -14,6 +19,8 @@ def health() -> dict[str, str]:
@app.get("/health/dependencies")
def dependency_health() -> JSONResponse:
from src.infrastructure.dependencies import check_dependencies
dependencies = check_dependencies()
is_ok = all(status == "ok" for status in dependencies.values())
@@ -25,3 +32,20 @@ def dependency_health() -> JSONResponse:
"dependencies": dependencies,
},
)
def custom_openapi() -> dict[str, Any]:
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
routes=app.routes,
)
inject_contract_schemas(openapi_schema)
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
@@ -0,0 +1 @@
"""Backend HTTP route modules."""
@@ -0,0 +1,18 @@
from __future__ import annotations
from fastapi import APIRouter, status
from src.application.articles import build_placeholder_article_response
from src.domain.contracts import ArticleCreateRequest, ArticleCreateResponse
router = APIRouter(prefix="/api", tags=["articles"])
@router.post(
"/articles",
response_model=ArticleCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_article(request: ArticleCreateRequest) -> ArticleCreateResponse:
return build_placeholder_article_response(request)
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,77 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from pydantic import ValidationError
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.domain.contracts import ( # noqa: E402
ARTICLE_WORKFLOW_STATUSES,
ARTICLE_WORKFLOW_TRANSITIONS,
ArticleCreateRequest,
ArticleWorkflowStatus,
Role,
UserSummary,
)
class DomainContractTest(unittest.TestCase):
def test_role_enum_is_canonical(self) -> None:
self.assertEqual(["ADMIN", "EDITOR"], [role.value for role in Role])
with self.assertRaises(ValidationError):
UserSummary.model_validate(
{
"id": "00000000-0000-0000-0000-000000000001",
"display_name": "Writer",
"role": "WRITER",
}
)
def test_article_create_rejects_missing_required_fields(self) -> None:
with self.assertRaises(ValidationError) as error:
ArticleCreateRequest.model_validate({})
missing_fields = {
issue["loc"][0]
for issue in error.exception.errors()
if issue["type"] == "missing"
}
self.assertEqual({"brief_description", "target_site_id"}, missing_fields)
def test_workflow_statuses_and_transitions_are_shared_constants(self) -> None:
self.assertEqual(
[
"ARTICLE_BRIEF_CREATED",
"BOUNDARY_QUESTIONS_GENERATED",
"BOUNDARY_ANSWERS_SUBMITTED",
"PLAN_GENERATED",
"PLAN_REVIEW_REQUIRED",
"PLAN_REVISION_REQUIRED",
"RESEARCH_RUNNING",
"EVIDENCE_MATRIX_READY",
"PARALLEL_PRODUCTION_RUNNING",
"DRAFT_ASSEMBLED",
"SEO_AND_LANGUAGE_REVIEW_READY",
"FINAL_REVIEW_REQUIRED",
"FINAL_REVISION_REQUIRED",
"PUBLISH_DRY_RUN_REQUIRED",
"PUBLISH_COMMIT_READY",
"PUBLISH_COMMIT_CREATED",
],
[status.value for status in ARTICLE_WORKFLOW_STATUSES],
)
self.assertEqual(set(ArticleWorkflowStatus), set(ARTICLE_WORKFLOW_TRANSITIONS))
for status, next_statuses in ARTICLE_WORKFLOW_TRANSITIONS.items():
self.assertIsInstance(status, ArticleWorkflowStatus)
for next_status in next_statuses:
self.assertIsInstance(next_status, ArticleWorkflowStatus)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,37 @@
from __future__ import annotations
import json
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[4]
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
sys.path.insert(0, str(BACKEND_ROOT))
from src.presentation.main import app # noqa: E402
class GeneratedContractArtifactsTest(unittest.TestCase):
def test_openapi_snapshot_matches_backend_openapi(self) -> None:
snapshot_path = REPO_ROOT / "packages" / "shared" / "openapi.json"
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
self.assertEqual(app.openapi(), snapshot)
def test_typescript_types_are_marked_generated(self) -> None:
types_path = REPO_ROOT / "packages" / "shared" / "src" / "api-types.ts"
content = types_path.read_text(encoding="utf-8")
self.assertTrue(
content.startswith(
"// Generated from backend OpenAPI by "
"scripts/generate_openapi_contracts.py."
)
)
self.assertIn("export type ArticleCreateRequest", content)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,188 @@
from __future__ import annotations
import sys
import types
import unittest
from importlib.util import find_spec
from pathlib import Path
from typing import Any
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
def _module_available(name: str) -> bool:
try:
return find_spec(name) is not None
except (ModuleNotFoundError, ValueError):
return False
def _install_optional_dependency_stubs() -> None:
# Health routes import external clients; this contract test only reads OpenAPI.
if not _module_available("boto3"):
boto3 = types.ModuleType("boto3")
boto3.client = lambda *args, **kwargs: None
sys.modules["boto3"] = boto3
if not _module_available("psycopg"):
psycopg = types.ModuleType("psycopg")
psycopg.connect = lambda *args, **kwargs: None
sys.modules["psycopg"] = psycopg
if not _module_available("redis"):
redis = types.ModuleType("redis")
class Redis:
@staticmethod
def from_url(*args: Any, **kwargs: Any) -> None:
return None
redis.Redis = Redis
sys.modules["redis"] = redis
if not _module_available("botocore.config"):
botocore = types.ModuleType("botocore")
config = types.ModuleType("botocore.config")
class Config:
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
config.Config = Config
sys.modules["botocore"] = botocore
sys.modules["botocore.config"] = config
_install_optional_dependency_stubs()
from src.presentation.main import app # noqa: E402
class BackendPublicOpenApiContractTest(unittest.TestCase):
def setUp(self) -> None:
self.openapi = app.openapi()
self.schemas = self.openapi.get("components", {}).get("schemas", {})
def test_article_create_request_contract_is_public_and_rejects_invalid_payloads(
self,
) -> None:
failures: list[str] = []
article_schema = self.schemas.get("ArticleCreateRequest")
if article_schema is None:
failures.append("components.schemas.ArticleCreateRequest is missing")
else:
required = set(article_schema.get("required", []))
for field in ("brief_description", "target_site_id"):
if field not in required:
failures.append(f"ArticleCreateRequest must require {field!r}")
invalid_article_errors = self._validation_errors(
schema_name="ArticleCreateRequest",
payload={},
)
if not self._has_required_error(invalid_article_errors, "brief_description"):
failures.append(
"ArticleCreateRequest contract must reject payloads missing "
"'brief_description'"
)
if not self._has_required_error(invalid_article_errors, "target_site_id"):
failures.append(
"ArticleCreateRequest contract must reject payloads missing "
"'target_site_id'"
)
request_ref = self._json_request_schema_ref(path="/api/articles", method="post")
if request_ref != "#/components/schemas/ArticleCreateRequest":
failures.append(
"POST /api/articles must expose ArticleCreateRequest as its JSON "
f"request body; got {request_ref or 'no request body'}"
)
role_schema = self.schemas.get("Role")
if role_schema is None:
failures.append("components.schemas.Role is missing")
else:
role_values = self._enum_values(role_schema)
if role_values != ["ADMIN", "EDITOR"]:
failures.append(
"Role enum must be exactly ['ADMIN', 'EDITOR']; "
f"got {role_values!r}"
)
if not self._has_enum_error("Role", "WRITER"):
failures.append("Role contract must reject invalid value 'WRITER'")
workflow_status_schema = self.schemas.get("ArticleWorkflowStatus")
if workflow_status_schema is None:
failures.append("components.schemas.ArticleWorkflowStatus is missing")
elif not self._has_enum_error("ArticleWorkflowStatus", "NOT_A_STATUS"):
failures.append(
"ArticleWorkflowStatus contract must reject invalid workflow statuses"
)
self.assertEqual([], failures)
def _json_request_schema_ref(self, *, path: str, method: str) -> str | None:
operation = self.openapi.get("paths", {}).get(path, {}).get(method, {})
content = operation.get("requestBody", {}).get("content", {})
schema = content.get("application/json", {}).get("schema", {})
return schema.get("$ref")
def _has_required_error(self, errors: list[str], field: str) -> bool:
return any(error == f"missing required field: {field}" for error in errors)
def _has_enum_error(self, schema_name: str, value: str) -> bool:
return f"invalid enum value: {value}" in self._validation_errors(
schema_name=schema_name,
payload=value,
)
def _validation_errors(self, *, schema_name: str, payload: Any) -> list[str]:
schema = self.schemas.get(schema_name)
if schema is None:
return [f"missing schema: {schema_name}"]
enum_values = self._enum_values(schema)
if enum_values is not None:
return [] if payload in enum_values else [f"invalid enum value: {payload}"]
if schema.get("type") != "object" or not isinstance(payload, dict):
return []
required = schema.get("required", [])
return [
f"missing required field: {field}"
for field in required
if field not in payload
]
def _enum_values(self, schema: dict[str, Any]) -> list[str] | None:
enum_values = schema.get("enum")
if isinstance(enum_values, list):
return enum_values
for composite_key in ("allOf", "anyOf", "oneOf"):
for item in schema.get(composite_key, []):
resolved = self._resolve_schema(item)
values = self._enum_values(resolved)
if values is not None:
return values
return None
def _resolve_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
ref = schema.get("$ref")
if not isinstance(ref, str):
return schema
prefix = "#/components/schemas/"
if not ref.startswith(prefix):
return schema
return self.schemas.get(ref.removeprefix(prefix), schema)
if __name__ == "__main__":
unittest.main()