msaas-forms 0.1.0__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.
forms/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Willian Forms -- Dynamic form builder and submission handler for SaaS applications."""
2
+
3
+ from forms.config import FormsConfig, get_config, get_forms, init_forms
4
+ from forms.models import (
5
+ FieldType,
6
+ FormDefinition,
7
+ FormField,
8
+ FormSettings,
9
+ FormSubmission,
10
+ ValidationError,
11
+ ValidationRule,
12
+ ValidationType,
13
+ )
14
+ from forms.service import FormService
15
+ from forms.store import FormStore, InMemoryFormStore
16
+ from forms.validator import FormValidator
17
+
18
+ __all__ = [
19
+ "FieldType",
20
+ "FormDefinition",
21
+ "FormField",
22
+ "FormService",
23
+ "FormSettings",
24
+ "FormStore",
25
+ "FormSubmission",
26
+ "FormValidator",
27
+ "FormsConfig",
28
+ "InMemoryFormStore",
29
+ "ValidationError",
30
+ "ValidationRule",
31
+ "ValidationType",
32
+ "get_config",
33
+ "get_forms",
34
+ "init_forms",
35
+ ]
forms/config.py ADDED
@@ -0,0 +1,73 @@
1
+ """Forms module configuration and initialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ _config: FormsConfig | None = None
9
+ _service: object | None = None # Lazy ref to avoid circular import
10
+
11
+
12
+ class FormsConfig(BaseModel):
13
+ """Configuration for the forms module.
14
+
15
+ Args:
16
+ max_fields_per_form: Maximum number of fields allowed in a single form.
17
+ max_submissions_per_form: Default cap on submissions per form (0 = unlimited).
18
+ rate_limit_per_minute: Maximum submissions per form per minute.
19
+ """
20
+
21
+ max_fields_per_form: int = 50
22
+ max_submissions_per_form: int = 0
23
+ rate_limit_per_minute: int = 60
24
+
25
+
26
+ def init_forms(config: FormsConfig | None = None) -> FormsConfig:
27
+ """Initialize the forms module with the given configuration.
28
+
29
+ Args:
30
+ config: Forms configuration. Uses defaults if None.
31
+
32
+ Returns:
33
+ The active FormsConfig.
34
+ """
35
+ global _config, _service
36
+ _config = config or FormsConfig()
37
+ _service = None
38
+ return _config
39
+
40
+
41
+ def get_config() -> FormsConfig:
42
+ """Return the current module configuration.
43
+
44
+ Raises:
45
+ RuntimeError: If init_forms() has not been called.
46
+ """
47
+ if _config is None:
48
+ raise RuntimeError("Forms module not initialized. Call init_forms() first.")
49
+ return _config
50
+
51
+
52
+ def get_forms():
53
+ """Return or create the singleton FormService.
54
+
55
+ Raises:
56
+ RuntimeError: If init_forms() has not been called.
57
+ """
58
+ global _service
59
+ if _config is None:
60
+ raise RuntimeError("Forms module not initialized. Call init_forms() first.")
61
+ if _service is None:
62
+ from forms.service import FormService
63
+ from forms.store import InMemoryFormStore
64
+
65
+ _service = FormService(config=_config, store=InMemoryFormStore())
66
+ return _service
67
+
68
+
69
+ def reset() -> None:
70
+ """Reset module state (useful for testing)."""
71
+ global _config, _service
72
+ _config = None
73
+ _service = None
forms/models.py ADDED
@@ -0,0 +1,146 @@
1
+ """Pydantic models for form definitions, fields, submissions, and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class FieldType(str, enum.Enum):
13
+ """Supported form field types."""
14
+
15
+ TEXT = "text"
16
+ NUMBER = "number"
17
+ EMAIL = "email"
18
+ SELECT = "select"
19
+ MULTISELECT = "multiselect"
20
+ CHECKBOX = "checkbox"
21
+ TEXTAREA = "textarea"
22
+ DATE = "date"
23
+ FILE = "file"
24
+
25
+
26
+ class ValidationType(str, enum.Enum):
27
+ """Types of validation rules that can be applied to fields."""
28
+
29
+ MIN_LENGTH = "min_length"
30
+ MAX_LENGTH = "max_length"
31
+ PATTERN = "pattern"
32
+ MIN = "min"
33
+ MAX = "max"
34
+ CUSTOM = "custom"
35
+
36
+
37
+ class ValidationRule(BaseModel):
38
+ """A single validation constraint for a form field.
39
+
40
+ Args:
41
+ type: The kind of validation (min_length, pattern, etc.).
42
+ value: The constraint value (number, regex string, etc.).
43
+ message: Human-readable error message when validation fails.
44
+ """
45
+
46
+ type: ValidationType
47
+ value: Any
48
+ message: str = ""
49
+
50
+
51
+ class FormField(BaseModel):
52
+ """Definition of a single field within a form.
53
+
54
+ Args:
55
+ name: Machine-readable field identifier.
56
+ type: The input type for this field.
57
+ label: Human-readable label displayed to users.
58
+ required: Whether the field must be filled.
59
+ placeholder: Placeholder text shown in empty inputs.
60
+ validation_rules: List of validation constraints.
61
+ options: Choices for select/multiselect fields.
62
+ order: Display order (lower numbers appear first).
63
+ """
64
+
65
+ name: str
66
+ type: FieldType = FieldType.TEXT
67
+ label: str = ""
68
+ required: bool = False
69
+ placeholder: str = ""
70
+ validation_rules: list[ValidationRule] = Field(default_factory=list)
71
+ options: list[str] = Field(default_factory=list)
72
+ order: int = 0
73
+
74
+
75
+ class FormSettings(BaseModel):
76
+ """Configuration options for a form's behavior.
77
+
78
+ Args:
79
+ allow_anonymous: Whether unauthenticated users can submit.
80
+ max_submissions: Maximum total submissions (0 = unlimited).
81
+ close_after_date: ISO datetime after which submissions are rejected.
82
+ notification_emails: Email addresses to notify on new submissions.
83
+ """
84
+
85
+ allow_anonymous: bool = True
86
+ max_submissions: int = 0
87
+ close_after_date: datetime | None = None
88
+ notification_emails: list[str] = Field(default_factory=list)
89
+
90
+
91
+ class FormDefinition(BaseModel):
92
+ """A complete form definition with fields and settings.
93
+
94
+ Args:
95
+ id: Unique form identifier.
96
+ name: Human-readable form name.
97
+ description: Optional description of the form's purpose.
98
+ fields: Ordered list of form fields.
99
+ settings: Form behavior configuration.
100
+ created_at: When the form was created.
101
+ updated_at: When the form was last modified.
102
+ published: Whether the form accepts submissions.
103
+ """
104
+
105
+ id: str
106
+ name: str
107
+ description: str = ""
108
+ fields: list[FormField] = Field(default_factory=list)
109
+ settings: FormSettings = Field(default_factory=FormSettings)
110
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
111
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
112
+ published: bool = False
113
+
114
+
115
+ class FormSubmission(BaseModel):
116
+ """A single submission (response) to a form.
117
+
118
+ Args:
119
+ id: Unique submission identifier.
120
+ form_id: The form this submission belongs to.
121
+ data: Field name to value mapping of submitted answers.
122
+ submitted_by: Identifier of the user who submitted (None if anonymous).
123
+ submitted_at: When the submission was recorded.
124
+ ip_address: IP address of the submitter.
125
+ """
126
+
127
+ id: str
128
+ form_id: str
129
+ data: dict[str, Any] = Field(default_factory=dict)
130
+ submitted_by: str | None = None
131
+ submitted_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
132
+ ip_address: str | None = None
133
+
134
+
135
+ class ValidationError(BaseModel):
136
+ """A structured validation error tied to a specific field.
137
+
138
+ Args:
139
+ field: The field name that failed validation.
140
+ message: Human-readable description of the failure.
141
+ rule_type: The validation rule type that was violated.
142
+ """
143
+
144
+ field: str
145
+ message: str
146
+ rule_type: str | None = None
forms/router.py ADDED
@@ -0,0 +1,178 @@
1
+ """FastAPI router factory for form management and submission endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, HTTPException, Query, Request
8
+ from pydantic import BaseModel, Field
9
+
10
+ from forms.config import get_forms
11
+ from forms.models import (
12
+ FormDefinition,
13
+ FormField,
14
+ FormSettings,
15
+ FormSubmission,
16
+ )
17
+ from errors import AuthorizationError, BusinessLogicError, ConflictError, NotFoundError, ValidationError
18
+ from api_core.responses import ApiResponse, PaginatedResponse
19
+
20
+
21
+ class CreateFormRequest(BaseModel):
22
+ """Request body for creating a new form."""
23
+
24
+ name: str
25
+ description: str = ""
26
+ fields: list[FormField] = Field(default_factory=list)
27
+ settings: FormSettings = Field(default_factory=FormSettings)
28
+
29
+
30
+ class UpdateFormRequest(BaseModel):
31
+ """Request body for updating an existing form."""
32
+
33
+ name: str | None = None
34
+ description: str | None = None
35
+ fields: list[FormField] | None = None
36
+ settings: FormSettings | None = None
37
+
38
+
39
+ class SubmitFormRequest(BaseModel):
40
+ """Request body for submitting a response to a form."""
41
+
42
+ data: dict[str, Any]
43
+ submitted_by: str | None = None
44
+
45
+
46
+ class FormStatsResponse(BaseModel):
47
+ """Response body for form submission statistics."""
48
+
49
+ form_id: str
50
+ total_submissions: int
51
+ last_submission_at: str | None
52
+ published: bool
53
+
54
+
55
+ def create_forms_router(
56
+ *,
57
+ prefix: str = "",
58
+ tags: list[str] | None = None,
59
+ ) -> APIRouter:
60
+ """Create a FastAPI router with form management and submission endpoints.
61
+
62
+ The router uses get_forms() to obtain the FormService singleton,
63
+ so init_forms() must be called before any requests are handled.
64
+
65
+ Args:
66
+ prefix: URL prefix for all routes.
67
+ tags: OpenAPI tags.
68
+
69
+ Returns:
70
+ A configured APIRouter.
71
+ """
72
+ router = APIRouter(prefix=prefix, tags=tags or ["forms"])
73
+
74
+ @router.post("/forms", response_model=FormDefinition, status_code=201)
75
+ async def create_form(body: CreateFormRequest) -> FormDefinition:
76
+ """Create a new form definition."""
77
+ service = get_forms()
78
+ try:
79
+ return await service.create_form(
80
+ name=body.name,
81
+ description=body.description,
82
+ fields=body.fields,
83
+ settings=body.settings,
84
+ )
85
+ except ValueError as exc:
86
+ raise ValidationError(str(exc))
87
+
88
+ @router.get("/forms", response_model=list[FormDefinition])
89
+ async def list_forms(
90
+ published_only: bool = Query(False),
91
+ offset: int = Query(0, ge=0),
92
+ limit: int = Query(100, ge=1, le=1000),
93
+ ) -> list[FormDefinition]:
94
+ """List form definitions."""
95
+ service = get_forms()
96
+ return await service._store.list_forms(
97
+ published_only=published_only, offset=offset, limit=limit
98
+ )
99
+
100
+ @router.get("/forms/{form_id}", response_model=FormDefinition)
101
+ async def get_form(form_id: str) -> FormDefinition:
102
+ """Get a form definition by ID."""
103
+ service = get_forms()
104
+ form = await service._store.get_form(form_id)
105
+ if form is None:
106
+ raise NotFoundError("Form not found.")
107
+ return form
108
+
109
+ @router.put("/forms/{form_id}", response_model=FormDefinition)
110
+ async def update_form(form_id: str, body: UpdateFormRequest) -> FormDefinition:
111
+ """Update an existing form definition."""
112
+ service = get_forms()
113
+ try:
114
+ return await service.update_form(
115
+ form_id,
116
+ name=body.name,
117
+ description=body.description,
118
+ fields=body.fields,
119
+ settings=body.settings,
120
+ )
121
+ except KeyError:
122
+ raise NotFoundError("Form not found.")
123
+ except ValueError as exc:
124
+ raise ValidationError(str(exc))
125
+
126
+ @router.post("/forms/{form_id}/publish", response_model=FormDefinition)
127
+ async def publish_form(form_id: str) -> FormDefinition:
128
+ """Publish a form so it accepts submissions."""
129
+ service = get_forms()
130
+ try:
131
+ return await service.publish_form(form_id)
132
+ except KeyError:
133
+ raise NotFoundError("Form not found.")
134
+ except ValueError as exc:
135
+ raise ValidationError(str(exc))
136
+
137
+ @router.post("/forms/{form_id}/submit", response_model=FormSubmission, status_code=201)
138
+ async def submit_form(
139
+ form_id: str, body: SubmitFormRequest, request: Request
140
+ ) -> FormSubmission:
141
+ """Submit a response to a published form."""
142
+ service = get_forms()
143
+ ip = request.client.host if request.client else None
144
+ try:
145
+ return await service.submit_form(
146
+ form_id,
147
+ body.data,
148
+ submitted_by=body.submitted_by,
149
+ ip_address=ip,
150
+ )
151
+ except KeyError:
152
+ raise NotFoundError("Form not found.")
153
+ except PermissionError as exc:
154
+ raise AuthorizationError(str(exc))
155
+ except ValueError as exc:
156
+ raise ValidationError(str(exc))
157
+
158
+ @router.get("/forms/{form_id}/submissions", response_model=list[FormSubmission])
159
+ async def get_submissions(
160
+ form_id: str,
161
+ offset: int = Query(0, ge=0),
162
+ limit: int = Query(100, ge=1, le=1000),
163
+ ) -> list[FormSubmission]:
164
+ """List submissions for a form."""
165
+ service = get_forms()
166
+ return await service.get_submissions(form_id, offset=offset, limit=limit)
167
+
168
+ @router.get("/forms/{form_id}/stats", response_model=FormStatsResponse)
169
+ async def get_stats(form_id: str) -> FormStatsResponse:
170
+ """Get submission statistics for a form."""
171
+ service = get_forms()
172
+ try:
173
+ stats = await service.get_stats(form_id)
174
+ return FormStatsResponse(**stats)
175
+ except KeyError:
176
+ raise NotFoundError("Form not found.")
177
+
178
+ return router
forms/service.py ADDED
@@ -0,0 +1,235 @@
1
+ """Form service for orchestrating form lifecycle and submissions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ import uuid
8
+ from collections import defaultdict
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ from forms.config import FormsConfig
13
+ from forms.models import (
14
+ FormDefinition,
15
+ FormField,
16
+ FormSettings,
17
+ FormSubmission,
18
+ ValidationError,
19
+ )
20
+ from forms.store import FormStore
21
+ from forms.validator import FormValidator
22
+ from errors import NotFoundError, ValidationError
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class FormService:
28
+ """Orchestrates form creation, updates, publishing, and submissions.
29
+
30
+ Args:
31
+ config: Module configuration.
32
+ store: Storage backend for forms and submissions.
33
+ validator: Submission validator. Created automatically if None.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ config: FormsConfig,
39
+ store: FormStore,
40
+ validator: FormValidator | None = None,
41
+ ) -> None:
42
+ self._config = config
43
+ self._store = store
44
+ self._validator = validator or FormValidator()
45
+ self._rate_limiter: dict[str, list[float]] = defaultdict(list)
46
+
47
+ @property
48
+ def validator(self) -> FormValidator:
49
+ """Access the underlying form validator (e.g. to register custom hooks)."""
50
+ return self._validator
51
+
52
+ async def create_form(
53
+ self,
54
+ *,
55
+ name: str,
56
+ description: str = "",
57
+ fields: list[FormField] | None = None,
58
+ settings: FormSettings | None = None,
59
+ ) -> FormDefinition:
60
+ """Create a new form definition.
61
+
62
+ Raises ValueError if field count exceeds max_fields_per_form.
63
+ """
64
+ fields = fields or []
65
+ if len(fields) > self._config.max_fields_per_form:
66
+ raise ValidationError(
67
+ f"Too many fields: {len(fields)} > {self._config.max_fields_per_form}"
68
+ )
69
+
70
+ now = datetime.now(timezone.utc)
71
+ form = FormDefinition(
72
+ id=str(uuid.uuid4()),
73
+ name=name,
74
+ description=description,
75
+ fields=fields,
76
+ settings=settings or FormSettings(),
77
+ created_at=now,
78
+ updated_at=now,
79
+ )
80
+ await self._store.save_form(form)
81
+ logger.info("Form created: id=%s name=%s", form.id, form.name)
82
+ return form
83
+
84
+ async def update_form(
85
+ self,
86
+ form_id: str,
87
+ *,
88
+ name: str | None = None,
89
+ description: str | None = None,
90
+ fields: list[FormField] | None = None,
91
+ settings: FormSettings | None = None,
92
+ ) -> FormDefinition:
93
+ """Update an existing form. Raises KeyError if not found."""
94
+ form = await self._store.get_form(form_id)
95
+ if form is None:
96
+ raise KeyError(f"Form not found: {form_id}")
97
+
98
+ updates: dict[str, Any] = {"updated_at": datetime.now(timezone.utc)}
99
+ if name is not None:
100
+ updates["name"] = name
101
+ if description is not None:
102
+ updates["description"] = description
103
+ if fields is not None:
104
+ if len(fields) > self._config.max_fields_per_form:
105
+ raise ValidationError(
106
+ f"Too many fields: {len(fields)} > {self._config.max_fields_per_form}"
107
+ )
108
+ updates["fields"] = fields
109
+ if settings is not None:
110
+ updates["settings"] = settings
111
+
112
+ updated = form.model_copy(update=updates)
113
+ await self._store.save_form(updated)
114
+ logger.info("Form updated: id=%s", form_id)
115
+ return updated
116
+
117
+ async def publish_form(self, form_id: str) -> FormDefinition:
118
+ """Publish a form so it accepts submissions. Raises ValueError if no fields."""
119
+ form = await self._store.get_form(form_id)
120
+ if form is None:
121
+ raise KeyError(f"Form not found: {form_id}")
122
+ if not form.fields:
123
+ raise ValidationError("Cannot publish a form with no fields.")
124
+
125
+ updated = form.model_copy(update={
126
+ "published": True,
127
+ "updated_at": datetime.now(timezone.utc),
128
+ })
129
+ await self._store.save_form(updated)
130
+ logger.info("Form published: id=%s", form_id)
131
+ return updated
132
+
133
+ async def submit_form(
134
+ self,
135
+ form_id: str,
136
+ data: dict[str, Any],
137
+ *,
138
+ submitted_by: str | None = None,
139
+ ip_address: str | None = None,
140
+ ) -> FormSubmission:
141
+ """Submit a response to a published form.
142
+
143
+ Raises KeyError (not found), PermissionError (closed/unpublished), ValueError (invalid).
144
+ """
145
+ form = await self._store.get_form(form_id)
146
+ if form is None:
147
+ raise KeyError(f"Form not found: {form_id}")
148
+ if not form.published:
149
+ raise PermissionError("Form is not published.")
150
+ if not form.settings.allow_anonymous and submitted_by is None:
151
+ raise PermissionError("Anonymous submissions are not allowed.")
152
+
153
+ # Check close date
154
+ if form.settings.close_after_date:
155
+ if datetime.now(timezone.utc) > form.settings.close_after_date:
156
+ raise PermissionError("Form is closed for submissions.")
157
+
158
+ # Check max submissions
159
+ if form.settings.max_submissions > 0:
160
+ count = await self._store.count_submissions(form_id)
161
+ if count >= form.settings.max_submissions:
162
+ raise PermissionError("Form has reached maximum submissions.")
163
+
164
+ # Rate limiting
165
+ self._check_rate_limit(form_id)
166
+
167
+ # Validate
168
+ errors = self._validator.validate_submission(form, data)
169
+ if errors:
170
+ details = "; ".join(f"{e.field}: {e.message}" for e in errors)
171
+ raise ValidationError(f"Validation failed: {details}")
172
+
173
+ submission = FormSubmission(
174
+ id=str(uuid.uuid4()),
175
+ form_id=form_id,
176
+ data=data,
177
+ submitted_by=submitted_by,
178
+ ip_address=ip_address,
179
+ )
180
+ await self._store.save_submission(submission)
181
+ logger.info("Submission recorded: id=%s form=%s", submission.id, form_id)
182
+ return submission
183
+
184
+ async def get_submissions(
185
+ self, form_id: str, *, offset: int = 0, limit: int = 100
186
+ ) -> list[FormSubmission]:
187
+ """List submissions for a form with pagination."""
188
+ return await self._store.get_submissions(form_id, offset=offset, limit=limit)
189
+
190
+ async def get_stats(self, form_id: str) -> dict[str, Any]:
191
+ """Get submission count and last_submission_at. Raises KeyError if not found."""
192
+ form = await self._store.get_form(form_id)
193
+ if form is None:
194
+ raise KeyError(f"Form not found: {form_id}")
195
+
196
+ count = await self._store.count_submissions(form_id)
197
+ last_sub = None
198
+ if count > 0:
199
+ subs = await self._store.get_submissions(form_id, offset=0, limit=1)
200
+ if subs:
201
+ last_sub = subs[0].submitted_at.isoformat()
202
+
203
+ return {
204
+ "form_id": form_id,
205
+ "total_submissions": count,
206
+ "last_submission_at": last_sub,
207
+ "published": form.published,
208
+ }
209
+
210
+ async def export_submissions(self, form_id: str) -> list[dict[str, Any]]:
211
+ """Export all submissions as flat dicts (for data-export integration)."""
212
+ subs = await self._store.get_submissions(form_id, offset=0, limit=100_000)
213
+ return [
214
+ {
215
+ "id": s.id,
216
+ "submitted_by": s.submitted_by or "anonymous",
217
+ "submitted_at": s.submitted_at.isoformat(),
218
+ **s.data,
219
+ }
220
+ for s in subs
221
+ ]
222
+
223
+ def _check_rate_limit(self, form_id: str) -> None:
224
+ """Enforce per-form submission rate limiting."""
225
+ now = time.monotonic()
226
+ window = 60.0
227
+ timestamps = self._rate_limiter[form_id]
228
+
229
+ # Prune old entries
230
+ self._rate_limiter[form_id] = [t for t in timestamps if now - t < window]
231
+ timestamps = self._rate_limiter[form_id]
232
+
233
+ if len(timestamps) >= self._config.rate_limit_per_minute:
234
+ raise PermissionError("Rate limit exceeded. Try again later.")
235
+ timestamps.append(now)
forms/store.py ADDED
@@ -0,0 +1,142 @@
1
+ """Form and submission storage backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ from typing import Any
7
+
8
+ from forms.models import FormDefinition, FormSubmission
9
+
10
+
11
+ class FormStore(abc.ABC):
12
+ """Abstract base class for form storage backends."""
13
+
14
+ @abc.abstractmethod
15
+ async def save_form(self, form: FormDefinition) -> FormDefinition:
16
+ """Persist a form definition (create or update).
17
+
18
+ Args:
19
+ form: The form definition to save.
20
+
21
+ Returns:
22
+ The saved form definition.
23
+ """
24
+
25
+ @abc.abstractmethod
26
+ async def get_form(self, form_id: str) -> FormDefinition | None:
27
+ """Retrieve a form definition by ID.
28
+
29
+ Args:
30
+ form_id: The unique form identifier.
31
+
32
+ Returns:
33
+ The form definition, or None if not found.
34
+ """
35
+
36
+ @abc.abstractmethod
37
+ async def list_forms(
38
+ self, *, published_only: bool = False, offset: int = 0, limit: int = 100
39
+ ) -> list[FormDefinition]:
40
+ """List form definitions with optional filtering.
41
+
42
+ Args:
43
+ published_only: If True, only return published forms.
44
+ offset: Number of forms to skip.
45
+ limit: Maximum number of forms to return.
46
+
47
+ Returns:
48
+ List of form definitions.
49
+ """
50
+
51
+ @abc.abstractmethod
52
+ async def delete_form(self, form_id: str) -> bool:
53
+ """Delete a form definition and all its submissions.
54
+
55
+ Args:
56
+ form_id: The unique form identifier.
57
+
58
+ Returns:
59
+ True if the form existed and was deleted, False otherwise.
60
+ """
61
+
62
+ @abc.abstractmethod
63
+ async def save_submission(self, submission: FormSubmission) -> FormSubmission:
64
+ """Persist a form submission.
65
+
66
+ Args:
67
+ submission: The submission to save.
68
+
69
+ Returns:
70
+ The saved submission.
71
+ """
72
+
73
+ @abc.abstractmethod
74
+ async def get_submissions(
75
+ self, form_id: str, *, offset: int = 0, limit: int = 100
76
+ ) -> list[FormSubmission]:
77
+ """Retrieve submissions for a form.
78
+
79
+ Args:
80
+ form_id: The form to get submissions for.
81
+ offset: Number of submissions to skip.
82
+ limit: Maximum number of submissions to return.
83
+
84
+ Returns:
85
+ List of submissions ordered by submitted_at descending.
86
+ """
87
+
88
+ @abc.abstractmethod
89
+ async def count_submissions(self, form_id: str) -> int:
90
+ """Count total submissions for a form.
91
+
92
+ Args:
93
+ form_id: The form to count submissions for.
94
+
95
+ Returns:
96
+ Total number of submissions.
97
+ """
98
+
99
+
100
+ class InMemoryFormStore(FormStore):
101
+ """Dictionary-backed in-memory form store for development and testing."""
102
+
103
+ def __init__(self) -> None:
104
+ self._forms: dict[str, FormDefinition] = {}
105
+ self._submissions: dict[str, list[FormSubmission]] = {}
106
+
107
+ async def save_form(self, form: FormDefinition) -> FormDefinition:
108
+ self._forms[form.id] = form
109
+ return form
110
+
111
+ async def get_form(self, form_id: str) -> FormDefinition | None:
112
+ return self._forms.get(form_id)
113
+
114
+ async def list_forms(
115
+ self, *, published_only: bool = False, offset: int = 0, limit: int = 100
116
+ ) -> list[FormDefinition]:
117
+ forms = list(self._forms.values())
118
+ if published_only:
119
+ forms = [f for f in forms if f.published]
120
+ forms.sort(key=lambda f: f.created_at, reverse=True)
121
+ return forms[offset : offset + limit]
122
+
123
+ async def delete_form(self, form_id: str) -> bool:
124
+ if form_id not in self._forms:
125
+ return False
126
+ del self._forms[form_id]
127
+ self._submissions.pop(form_id, None)
128
+ return True
129
+
130
+ async def save_submission(self, submission: FormSubmission) -> FormSubmission:
131
+ self._submissions.setdefault(submission.form_id, []).append(submission)
132
+ return submission
133
+
134
+ async def get_submissions(
135
+ self, form_id: str, *, offset: int = 0, limit: int = 100
136
+ ) -> list[FormSubmission]:
137
+ subs = self._submissions.get(form_id, [])
138
+ sorted_subs = sorted(subs, key=lambda s: s.submitted_at, reverse=True)
139
+ return sorted_subs[offset : offset + limit]
140
+
141
+ async def count_submissions(self, form_id: str) -> int:
142
+ return len(self._submissions.get(form_id, []))
forms/validator.py ADDED
@@ -0,0 +1,235 @@
1
+ """Form submission validation engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from datetime import date, datetime
8
+ from typing import Any
9
+
10
+ from forms.models import (
11
+ FieldType,
12
+ FormDefinition,
13
+ FormField,
14
+ ValidationError,
15
+ ValidationType,
16
+ )
17
+
18
+ # Type alias for custom validator hooks
19
+ FieldValidator = Callable[[FormField, Any], ValidationError | None]
20
+
21
+
22
+ class FormValidator:
23
+ """Validates form submissions against their form definitions.
24
+
25
+ Supports required-field checks, type coercion, built-in validation
26
+ rules (min/max length, regex patterns, numeric range), and
27
+ per-field-type custom validator hooks.
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ self._custom_validators: dict[FieldType, list[FieldValidator]] = {}
32
+
33
+ def register_validator(self, field_type: FieldType, validator: FieldValidator) -> None:
34
+ """Register a custom validator hook for a specific field type.
35
+
36
+ Args:
37
+ field_type: The field type this validator applies to.
38
+ validator: Callable(field, value) -> ValidationError | None.
39
+ """
40
+ self._custom_validators.setdefault(field_type, []).append(validator)
41
+
42
+ def validate_submission(
43
+ self,
44
+ form: FormDefinition,
45
+ data: dict[str, Any],
46
+ ) -> list[ValidationError]:
47
+ """Validate submitted data against the form definition.
48
+
49
+ Args:
50
+ form: The form definition to validate against.
51
+ data: Field-name to value mapping from the submission.
52
+
53
+ Returns:
54
+ List of validation errors. Empty list means valid.
55
+ """
56
+ errors: list[ValidationError] = []
57
+ field_map = {f.name: f for f in form.fields}
58
+
59
+ for field in form.fields:
60
+ value = data.get(field.name)
61
+ if value is None or (isinstance(value, str) and value.strip() == ""):
62
+ if field.required:
63
+ errors.append(ValidationError(
64
+ field=field.name,
65
+ message=f"Field '{field.label or field.name}' is required.",
66
+ rule_type="required",
67
+ ))
68
+ continue
69
+
70
+ coerced, coerce_err = self._coerce_type(field, value)
71
+ if coerce_err:
72
+ errors.append(coerce_err)
73
+ continue
74
+
75
+ errors.extend(self._validate_rules(field, coerced))
76
+ errors.extend(self._run_custom_validators(field, coerced))
77
+
78
+ # Warn about unknown fields
79
+ known = set(field_map.keys())
80
+ for key in data:
81
+ if key not in known:
82
+ errors.append(ValidationError(
83
+ field=key,
84
+ message=f"Unknown field '{key}'.",
85
+ rule_type="unknown_field",
86
+ ))
87
+
88
+ return errors
89
+
90
+ def _coerce_type(
91
+ self, field: FormField, value: Any
92
+ ) -> tuple[Any, ValidationError | None]:
93
+ """Attempt to coerce value to the expected type for the field."""
94
+ try:
95
+ match field.type:
96
+ case FieldType.NUMBER:
97
+ return self._coerce_number(value), None
98
+ case FieldType.EMAIL:
99
+ return self._coerce_email(field, value)
100
+ case FieldType.CHECKBOX:
101
+ return bool(value), None
102
+ case FieldType.SELECT:
103
+ return self._coerce_select(field, value)
104
+ case FieldType.MULTISELECT:
105
+ return self._coerce_multiselect(field, value)
106
+ case FieldType.DATE:
107
+ return self._coerce_date(value), None
108
+ case _:
109
+ return str(value), None
110
+ except (ValueError, TypeError) as exc:
111
+ return None, ValidationError(
112
+ field=field.name,
113
+ message=str(exc),
114
+ rule_type="type",
115
+ )
116
+
117
+ @staticmethod
118
+ def _coerce_number(value: Any) -> int | float:
119
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
120
+ return value
121
+ return float(value)
122
+
123
+ @staticmethod
124
+ def _coerce_email(
125
+ field: FormField, value: Any
126
+ ) -> tuple[str, ValidationError | None]:
127
+ s = str(value).strip()
128
+ if "@" not in s or "." not in s.split("@")[-1]:
129
+ return s, ValidationError(
130
+ field=field.name,
131
+ message=f"'{s}' is not a valid email address.",
132
+ rule_type="type",
133
+ )
134
+ return s, None
135
+
136
+ @staticmethod
137
+ def _coerce_select(
138
+ field: FormField, value: Any
139
+ ) -> tuple[str, ValidationError | None]:
140
+ s = str(value)
141
+ if field.options and s not in field.options:
142
+ return s, ValidationError(
143
+ field=field.name,
144
+ message=f"'{s}' is not a valid option. Choose from: {field.options}.",
145
+ rule_type="type",
146
+ )
147
+ return s, None
148
+
149
+ @staticmethod
150
+ def _coerce_multiselect(
151
+ field: FormField, value: Any
152
+ ) -> tuple[list[str], ValidationError | None]:
153
+ if isinstance(value, str):
154
+ items = [v.strip() for v in value.split(",")]
155
+ elif isinstance(value, list):
156
+ items = [str(v) for v in value]
157
+ else:
158
+ items = [str(value)]
159
+
160
+ if field.options:
161
+ invalid = [v for v in items if v not in field.options]
162
+ if invalid:
163
+ return items, ValidationError(
164
+ field=field.name,
165
+ message=f"Invalid options: {invalid}. Choose from: {field.options}.",
166
+ rule_type="type",
167
+ )
168
+ return items, None
169
+
170
+ @staticmethod
171
+ def _coerce_date(value: Any) -> str:
172
+ if isinstance(value, (date, datetime)):
173
+ return value.isoformat()
174
+ # Validate parseable date string
175
+ for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
176
+ try:
177
+ datetime.strptime(str(value), fmt)
178
+ return str(value)
179
+ except ValueError:
180
+ continue
181
+ raise ValueError(f"'{value}' is not a valid date format. Use YYYY-MM-DD.")
182
+
183
+ @staticmethod
184
+ def _validate_rules(field: FormField, value: Any) -> list[ValidationError]:
185
+ """Apply built-in validation rules to a coerced value."""
186
+ errors: list[ValidationError] = []
187
+ for rule in field.validation_rules:
188
+ match rule.type:
189
+ case ValidationType.MIN_LENGTH:
190
+ if isinstance(value, str) and len(value) < int(rule.value):
191
+ errors.append(ValidationError(
192
+ field=field.name,
193
+ message=rule.message or f"Must be at least {rule.value} characters.",
194
+ rule_type=rule.type.value,
195
+ ))
196
+ case ValidationType.MAX_LENGTH:
197
+ if isinstance(value, str) and len(value) > int(rule.value):
198
+ errors.append(ValidationError(
199
+ field=field.name,
200
+ message=rule.message or f"Must be at most {rule.value} characters.",
201
+ rule_type=rule.type.value,
202
+ ))
203
+ case ValidationType.PATTERN:
204
+ if isinstance(value, str) and not re.fullmatch(str(rule.value), value):
205
+ errors.append(ValidationError(
206
+ field=field.name,
207
+ message=rule.message or f"Does not match pattern '{rule.value}'.",
208
+ rule_type=rule.type.value,
209
+ ))
210
+ case ValidationType.MIN:
211
+ if isinstance(value, (int, float)) and value < float(rule.value):
212
+ errors.append(ValidationError(
213
+ field=field.name,
214
+ message=rule.message or f"Must be at least {rule.value}.",
215
+ rule_type=rule.type.value,
216
+ ))
217
+ case ValidationType.MAX:
218
+ if isinstance(value, (int, float)) and value > float(rule.value):
219
+ errors.append(ValidationError(
220
+ field=field.name,
221
+ message=rule.message or f"Must be at most {rule.value}.",
222
+ rule_type=rule.type.value,
223
+ ))
224
+ return errors
225
+
226
+ def _run_custom_validators(
227
+ self, field: FormField, value: Any
228
+ ) -> list[ValidationError]:
229
+ """Run registered custom validators for the field's type."""
230
+ errors: list[ValidationError] = []
231
+ for validator in self._custom_validators.get(field.type, []):
232
+ err = validator(field, value)
233
+ if err is not None:
234
+ errors.append(err)
235
+ return errors
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: msaas-forms
3
+ Version: 0.1.0
4
+ Summary: Dynamic form builder and submission handler for SaaS applications
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: msaas-api-core
8
+ Requires-Dist: msaas-errors
9
+ Requires-Dist: pydantic>=2.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: fastapi>=0.115.0; extra == 'dev'
12
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
13
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
14
+ Requires-Dist: pytest>=8.0; extra == 'dev'
15
+ Provides-Extra: fastapi
16
+ Requires-Dist: fastapi>=0.115.0; extra == 'fastapi'
@@ -0,0 +1,10 @@
1
+ forms/__init__.py,sha256=BjUcJK67rWoAWwH_ZipqeWl2XkblasN6-UEcbC4J2x0,808
2
+ forms/config.py,sha256=GxZNRvnOllSaUq0BjF8M43JJmqOOFHliciJSioF3bro,1960
3
+ forms/models.py,sha256=7h1Uj43quYmk6Y5TVgV1lnKzNkboW8Ahm1nAdbFEbxI,4427
4
+ forms/router.py,sha256=1gmWkLWztimWKCQlwQgdadI9KeK2RGT9xbcUuqdbOSo,5940
5
+ forms/service.py,sha256=cCLiJV07hfeJnnDa6tGASC96-VuOlztSsVpzqgT0djw,8229
6
+ forms/store.py,sha256=AUlQxpMc0T4kr35VP-K7H4mQ9b5Yr6FflGU0iUOF43A,4331
7
+ forms/validator.py,sha256=uD9wNRynUucVndIkjs1VqhLSQs80RUrTwZ6v398inBo,8978
8
+ msaas_forms-0.1.0.dist-info/METADATA,sha256=i8SrHJlLwWGA-x2z_GiCrVCFVzLNBf47QZkApIy6hOw,540
9
+ msaas_forms-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ msaas_forms-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any