dockerhub-api 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.
@@ -0,0 +1,756 @@
1
+ """Pydantic input models for the Docker Hub API client.
2
+
3
+ CONCEPT:HUB-1.0 — core wrapper.
4
+
5
+ Each model validates the caller-supplied arguments for one endpoint family
6
+ and builds the query parameters (``api_parameters``) and/or request body
7
+ (``payload``) in ``model_post_init``, mirroring the gitlab-api convention.
8
+ """
9
+
10
+ from pydantic import BaseModel, Field, field_validator, model_validator
11
+
12
+ PAT_SCOPES = {"repo:admin", "repo:write", "repo:read", "repo:public_read"}
13
+ OAT_RESOURCE_TYPES = {"TYPE_REPO", "TYPE_ORG"}
14
+ REPOSITORY_ORDERING = {
15
+ "name",
16
+ "-name",
17
+ "last_updated",
18
+ "-last_updated",
19
+ "pull_count",
20
+ "-pull_count",
21
+ }
22
+ ORG_ROLES = {"owner", "editor", "member"}
23
+ REPOSITORY_PERMISSIONS = {"read", "write", "admin"}
24
+ SCIM_SORT_ORDERS = {"ascending", "descending"}
25
+
26
+
27
+ class _PaginatedModel(BaseModel):
28
+ """Shared ``page`` / ``page_size`` query-parameter handling."""
29
+
30
+ page: int | None = Field(default=None, description="Pagination page", ge=1)
31
+ page_size: int | None = Field(
32
+ default=None, description="Results per page", ge=1, le=100
33
+ )
34
+ api_parameters: dict | None = Field(description="API parameters", default=None)
35
+
36
+ def model_post_init(self, _context):
37
+ self.api_parameters = {}
38
+ if self.page is not None:
39
+ self.api_parameters["page"] = self.page
40
+ if self.page_size is not None:
41
+ self.api_parameters["page_size"] = self.page_size
42
+
43
+
44
+ # --------------------------------------------------------------------------- #
45
+ # Auth
46
+ # --------------------------------------------------------------------------- #
47
+
48
+
49
+ class AuthTokenModel(BaseModel):
50
+ """``POST /v2/auth/token`` — mint a short-lived JWT bearer."""
51
+
52
+ identifier: str = Field(description="Username or organization name")
53
+ secret: str = Field(description="Password, PAT (dckr_pat_*), or org token")
54
+ payload: dict | None = Field(description="Request body", default=None)
55
+
56
+ def model_post_init(self, _context):
57
+ self.payload = {"identifier": self.identifier, "secret": self.secret}
58
+
59
+
60
+ class LoginModel(BaseModel):
61
+ """``POST /v2/users/login`` (deprecated first-factor login)."""
62
+
63
+ username: str
64
+ password: str
65
+ payload: dict | None = Field(description="Request body", default=None)
66
+
67
+ def model_post_init(self, _context):
68
+ self.payload = {"username": self.username, "password": self.password}
69
+
70
+
71
+ class TwoFactorLoginModel(BaseModel):
72
+ """``POST /v2/users/2fa-login`` (TOTP second factor)."""
73
+
74
+ login_2fa_token: str = Field(description="Token returned by the login call")
75
+ code: str = Field(description="TOTP code from the authenticator app")
76
+ payload: dict | None = Field(description="Request body", default=None)
77
+
78
+ def model_post_init(self, _context):
79
+ self.payload = {"login_2fa_token": self.login_2fa_token, "code": self.code}
80
+
81
+
82
+ # --------------------------------------------------------------------------- #
83
+ # Personal access tokens
84
+ # --------------------------------------------------------------------------- #
85
+
86
+
87
+ class AccessTokenListModel(_PaginatedModel):
88
+ """``GET /v2/access-tokens``."""
89
+
90
+
91
+ class AccessTokenCreateModel(BaseModel):
92
+ """``POST /v2/access-tokens``."""
93
+
94
+ token_label: str = Field(description="Human-readable token label")
95
+ scopes: list[str] = Field(description="Token scopes")
96
+ payload: dict | None = Field(description="Request body", default=None)
97
+
98
+ @field_validator("scopes")
99
+ @classmethod
100
+ def validate_scopes(cls, value: list[str]) -> list[str]:
101
+ invalid = sorted(set(value) - PAT_SCOPES)
102
+ if invalid:
103
+ raise ValueError(
104
+ f"Invalid PAT scopes {invalid}; valid scopes: {sorted(PAT_SCOPES)}"
105
+ )
106
+ if not value:
107
+ raise ValueError("At least one scope is required")
108
+ return value
109
+
110
+ def model_post_init(self, _context):
111
+ self.payload = {"token_label": self.token_label, "scopes": self.scopes}
112
+
113
+
114
+ class AccessTokenModel(BaseModel):
115
+ """``GET|DELETE /v2/access-tokens/{uuid}``."""
116
+
117
+ uuid: str = Field(description="Access token UUID")
118
+
119
+
120
+ class AccessTokenPatchModel(BaseModel):
121
+ """``PATCH /v2/access-tokens/{uuid}``."""
122
+
123
+ uuid: str = Field(description="Access token UUID")
124
+ token_label: str | None = None
125
+ is_active: bool | None = None
126
+ payload: dict | None = Field(description="Request body", default=None)
127
+
128
+ @model_validator(mode="after")
129
+ def validate_any_change(self):
130
+ if self.token_label is None and self.is_active is None:
131
+ raise ValueError("Provide token_label and/or is_active to update")
132
+ return self
133
+
134
+ def model_post_init(self, _context):
135
+ self.payload = {}
136
+ if self.token_label is not None:
137
+ self.payload["token_label"] = self.token_label
138
+ if self.is_active is not None:
139
+ self.payload["is_active"] = self.is_active
140
+
141
+
142
+ # --------------------------------------------------------------------------- #
143
+ # Organization access tokens
144
+ # --------------------------------------------------------------------------- #
145
+
146
+
147
+ class OrgAccessTokenListModel(_PaginatedModel):
148
+ """``GET /v2/orgs/{org}/access-tokens``."""
149
+
150
+ org: str = Field(description="Organization name")
151
+
152
+
153
+ class OrgAccessTokenCreateModel(BaseModel):
154
+ """``POST /v2/orgs/{org}/access-tokens``."""
155
+
156
+ org: str = Field(description="Organization name")
157
+ label: str = Field(description="Token label")
158
+ description: str | None = None
159
+ expires_at: str | None = Field(
160
+ default=None, description="RFC 3339 expiry timestamp"
161
+ )
162
+ scopes: list[str] | None = Field(
163
+ default=None, description="Org-wide scopes for the token"
164
+ )
165
+ resources: list[dict] | None = Field(
166
+ default=None,
167
+ description=(
168
+ "Scoped resources: [{'type': 'TYPE_REPO'|'TYPE_ORG', "
169
+ "'name': '<path glob>', 'scopes': [...]}]"
170
+ ),
171
+ )
172
+ payload: dict | None = Field(description="Request body", default=None)
173
+
174
+ @field_validator("resources")
175
+ @classmethod
176
+ def validate_resources(cls, value: list[dict] | None) -> list[dict] | None:
177
+ if value is None:
178
+ return value
179
+ for resource in value:
180
+ resource_type = resource.get("type")
181
+ if resource_type not in OAT_RESOURCE_TYPES:
182
+ raise ValueError(
183
+ f"Invalid resource type {resource_type!r}; "
184
+ f"valid types: {sorted(OAT_RESOURCE_TYPES)}"
185
+ )
186
+ return value
187
+
188
+ def model_post_init(self, _context):
189
+ self.payload = {"label": self.label}
190
+ if self.description is not None:
191
+ self.payload["description"] = self.description
192
+ if self.expires_at is not None:
193
+ self.payload["expires_at"] = self.expires_at
194
+ if self.scopes is not None:
195
+ self.payload["scopes"] = self.scopes
196
+ if self.resources is not None:
197
+ self.payload["resources"] = self.resources
198
+
199
+
200
+ class OrgAccessTokenModel(BaseModel):
201
+ """``GET|DELETE /v2/orgs/{org}/access-tokens/{id}``."""
202
+
203
+ org: str = Field(description="Organization name")
204
+ token_id: str = Field(description="Org access token identifier")
205
+
206
+
207
+ class OrgAccessTokenPatchModel(BaseModel):
208
+ """``PATCH /v2/orgs/{org}/access-tokens/{id}``."""
209
+
210
+ org: str = Field(description="Organization name")
211
+ token_id: str = Field(description="Org access token identifier")
212
+ label: str | None = None
213
+ description: str | None = None
214
+ is_active: bool | None = None
215
+ payload: dict | None = Field(description="Request body", default=None)
216
+
217
+ @model_validator(mode="after")
218
+ def validate_any_change(self):
219
+ if self.label is None and self.description is None and self.is_active is None:
220
+ raise ValueError("Provide label, description, and/or is_active to update")
221
+ return self
222
+
223
+ def model_post_init(self, _context):
224
+ self.payload = {}
225
+ if self.label is not None:
226
+ self.payload["label"] = self.label
227
+ if self.description is not None:
228
+ self.payload["description"] = self.description
229
+ if self.is_active is not None:
230
+ self.payload["is_active"] = self.is_active
231
+
232
+
233
+ # --------------------------------------------------------------------------- #
234
+ # Audit logs
235
+ # --------------------------------------------------------------------------- #
236
+
237
+
238
+ class AuditLogModel(_PaginatedModel):
239
+ """``GET /v2/auditlogs/{account}``."""
240
+
241
+ account: str = Field(description="Namespace (user or organization)")
242
+ action: str | None = Field(default=None, description="Filter by action name")
243
+ name: str | None = Field(default=None, description="Filter by object name")
244
+ actor: str | None = Field(default=None, description="Filter by actor username")
245
+ from_date: str | None = Field(
246
+ default=None, description="Window start (RFC 3339), sent as 'from'"
247
+ )
248
+ to_date: str | None = Field(
249
+ default=None, description="Window end (RFC 3339), sent as 'to'"
250
+ )
251
+
252
+ def model_post_init(self, _context):
253
+ super().model_post_init(_context)
254
+ assert self.api_parameters is not None
255
+ if self.action:
256
+ self.api_parameters["action"] = self.action
257
+ if self.name:
258
+ self.api_parameters["name"] = self.name
259
+ if self.actor:
260
+ self.api_parameters["actor"] = self.actor
261
+ if self.from_date:
262
+ self.api_parameters["from"] = self.from_date
263
+ if self.to_date:
264
+ self.api_parameters["to"] = self.to_date
265
+
266
+
267
+ # --------------------------------------------------------------------------- #
268
+ # Organization settings, members, and invites
269
+ # --------------------------------------------------------------------------- #
270
+
271
+
272
+ class OrgSettingsModel(BaseModel):
273
+ """``PUT /v2/orgs/{name}/settings``."""
274
+
275
+ org: str = Field(description="Organization name")
276
+ restricted_images_enabled: bool = Field(
277
+ description="Enable image-pull restrictions for the organization"
278
+ )
279
+ allow_official_images: bool = Field(
280
+ default=True, description="Allow Docker Official Images when restricted"
281
+ )
282
+ allow_verified_publishers: bool = Field(
283
+ default=True, description="Allow Verified Publisher images when restricted"
284
+ )
285
+ payload: dict | None = Field(description="Request body", default=None)
286
+
287
+ def model_post_init(self, _context):
288
+ self.payload = {
289
+ "restricted_images": {
290
+ "enabled": self.restricted_images_enabled,
291
+ "allow_official_images": self.allow_official_images,
292
+ "allow_verified_publishers": self.allow_verified_publishers,
293
+ }
294
+ }
295
+
296
+
297
+ class OrgMemberListModel(_PaginatedModel):
298
+ """``GET /v2/orgs/{org}/members``."""
299
+
300
+ org: str = Field(description="Organization name")
301
+ search: str | None = Field(default=None, description="Search by username/email")
302
+ member_type: str | None = Field(
303
+ default=None, description="Filter by membership type, sent as 'type'"
304
+ )
305
+ role: str | None = Field(default=None, description="Filter by role")
306
+
307
+ @field_validator("role")
308
+ @classmethod
309
+ def validate_role(cls, value: str | None) -> str | None:
310
+ if value is not None and value not in ORG_ROLES:
311
+ raise ValueError(
312
+ f"Invalid role {value!r}; valid roles: {sorted(ORG_ROLES)}"
313
+ )
314
+ return value
315
+
316
+ def model_post_init(self, _context):
317
+ super().model_post_init(_context)
318
+ assert self.api_parameters is not None
319
+ if self.search:
320
+ self.api_parameters["search"] = self.search
321
+ if self.member_type:
322
+ self.api_parameters["type"] = self.member_type
323
+ if self.role:
324
+ self.api_parameters["role"] = self.role
325
+
326
+
327
+ class OrgMemberUpdateModel(BaseModel):
328
+ """``PUT /v2/orgs/{org}/members/{username}``."""
329
+
330
+ org: str
331
+ username: str
332
+ role: str = Field(description="New role: owner, editor, or member")
333
+ payload: dict | None = Field(description="Request body", default=None)
334
+
335
+ @field_validator("role")
336
+ @classmethod
337
+ def validate_role(cls, value: str) -> str:
338
+ if value not in ORG_ROLES:
339
+ raise ValueError(
340
+ f"Invalid role {value!r}; valid roles: {sorted(ORG_ROLES)}"
341
+ )
342
+ return value
343
+
344
+ def model_post_init(self, _context):
345
+ self.payload = {"role": self.role}
346
+
347
+
348
+ class OrgMemberModel(BaseModel):
349
+ """``DELETE /v2/orgs/{org}/members/{username}``."""
350
+
351
+ org: str
352
+ username: str
353
+
354
+
355
+ class InviteModel(BaseModel):
356
+ """``DELETE /v2/invites/{id}`` and ``PATCH /v2/invites/{id}/resend``."""
357
+
358
+ invite_id: str = Field(description="Invite identifier")
359
+
360
+
361
+ class BulkInviteModel(BaseModel):
362
+ """``POST /v2/invites/bulk``."""
363
+
364
+ org: str = Field(description="Organization name")
365
+ invitees: list[str] = Field(description="Usernames or email addresses to invite")
366
+ team: str | None = Field(default=None, description="Team (group) to invite into")
367
+ role: str = Field(default="member", description="Org role for the invitees")
368
+ dry_run: bool = Field(default=False, description="Validate without sending")
369
+ payload: dict | None = Field(description="Request body", default=None)
370
+
371
+ @field_validator("invitees")
372
+ @classmethod
373
+ def validate_invitees(cls, value: list[str]) -> list[str]:
374
+ if not value:
375
+ raise ValueError("At least one invitee is required")
376
+ return value
377
+
378
+ @field_validator("role")
379
+ @classmethod
380
+ def validate_role(cls, value: str) -> str:
381
+ if value not in ORG_ROLES:
382
+ raise ValueError(
383
+ f"Invalid role {value!r}; valid roles: {sorted(ORG_ROLES)}"
384
+ )
385
+ return value
386
+
387
+ def model_post_init(self, _context):
388
+ self.payload = {
389
+ "org": self.org,
390
+ "invitees": self.invitees,
391
+ "role": self.role,
392
+ "dry_run": self.dry_run,
393
+ }
394
+ if self.team is not None:
395
+ self.payload["team"] = self.team
396
+
397
+
398
+ # --------------------------------------------------------------------------- #
399
+ # Repositories & tags
400
+ # --------------------------------------------------------------------------- #
401
+
402
+
403
+ class RepositoryListModel(_PaginatedModel):
404
+ """``GET /v2/namespaces/{namespace}/repositories``."""
405
+
406
+ namespace: str = Field(description="User or organization namespace")
407
+ name: str | None = Field(default=None, description="Filter by repository name")
408
+ ordering: str | None = Field(default=None, description="Result ordering")
409
+
410
+ @field_validator("ordering")
411
+ @classmethod
412
+ def validate_ordering(cls, value: str | None) -> str | None:
413
+ if value is not None and value not in REPOSITORY_ORDERING:
414
+ raise ValueError(
415
+ f"Invalid ordering {value!r}; valid: {sorted(REPOSITORY_ORDERING)}"
416
+ )
417
+ return value
418
+
419
+ def model_post_init(self, _context):
420
+ super().model_post_init(_context)
421
+ assert self.api_parameters is not None
422
+ if self.name:
423
+ self.api_parameters["name"] = self.name
424
+ if self.ordering:
425
+ self.api_parameters["ordering"] = self.ordering
426
+
427
+
428
+ class RepositoryCreateModel(BaseModel):
429
+ """``POST /v2/namespaces/{namespace}/repositories``."""
430
+
431
+ namespace: str = Field(description="User or organization namespace")
432
+ name: str = Field(description="Repository name (lowercase)")
433
+ description: str | None = Field(default=None, description="Short description")
434
+ full_description: str | None = Field(
435
+ default=None, description="Long-form (Markdown) description"
436
+ )
437
+ registry: str = Field(default="docker", description="Target registry")
438
+ is_private: bool = Field(default=False, description="Create as private")
439
+ payload: dict | None = Field(description="Request body", default=None)
440
+
441
+ @field_validator("name")
442
+ @classmethod
443
+ def validate_name(cls, value: str) -> str:
444
+ import re
445
+
446
+ if not re.fullmatch(r"[a-z0-9]+(?:[._-][a-z0-9]+)*", value):
447
+ raise ValueError(
448
+ "Repository names must be lowercase alphanumerics separated by "
449
+ "'.', '_', or '-'"
450
+ )
451
+ return value
452
+
453
+ def model_post_init(self, _context):
454
+ self.payload = {
455
+ "name": self.name,
456
+ "namespace": self.namespace,
457
+ "registry": self.registry,
458
+ "is_private": self.is_private,
459
+ }
460
+ if self.description is not None:
461
+ self.payload["description"] = self.description
462
+ if self.full_description is not None:
463
+ self.payload["full_description"] = self.full_description
464
+
465
+
466
+ class RepositoryModel(BaseModel):
467
+ """``GET|HEAD /v2/namespaces/{namespace}/repositories/{repository}``."""
468
+
469
+ namespace: str
470
+ repository: str
471
+
472
+
473
+ class TagListModel(_PaginatedModel):
474
+ """``GET|HEAD .../repositories/{repository}/tags``."""
475
+
476
+ namespace: str
477
+ repository: str
478
+
479
+
480
+ class TagModel(BaseModel):
481
+ """``GET|HEAD .../repositories/{repository}/tags/{tag}``."""
482
+
483
+ namespace: str
484
+ repository: str
485
+ tag: str
486
+
487
+
488
+ class ImmutableTagsPatchModel(BaseModel):
489
+ """``PATCH .../repositories/{repository}/immutabletags``."""
490
+
491
+ namespace: str
492
+ repository: str
493
+ enabled: bool = Field(description="Enable or disable tag immutability")
494
+ rules: list[str] | None = Field(
495
+ default=None, description="Tag patterns the immutability applies to"
496
+ )
497
+ payload: dict | None = Field(description="Request body", default=None)
498
+
499
+ def model_post_init(self, _context):
500
+ self.payload = {"enabled": self.enabled}
501
+ if self.rules is not None:
502
+ self.payload["rules"] = self.rules
503
+
504
+
505
+ class ImmutableTagsVerifyModel(BaseModel):
506
+ """``POST .../repositories/{repository}/immutabletags/verify``."""
507
+
508
+ namespace: str
509
+ repository: str
510
+ rules: list[str] | None = Field(
511
+ default=None, description="Candidate immutability rules to verify"
512
+ )
513
+ tags: list[str] | None = Field(
514
+ default=None, description="Tags to verify against the rules"
515
+ )
516
+ payload: dict | None = Field(description="Request body", default=None)
517
+
518
+ @model_validator(mode="after")
519
+ def validate_any_input(self):
520
+ if self.rules is None and self.tags is None:
521
+ raise ValueError("Provide rules and/or tags to verify")
522
+ return self
523
+
524
+ def model_post_init(self, _context):
525
+ self.payload = {}
526
+ if self.rules is not None:
527
+ self.payload["rules"] = self.rules
528
+ if self.tags is not None:
529
+ self.payload["tags"] = self.tags
530
+
531
+
532
+ class RepositoryGroupModel(BaseModel):
533
+ """``POST /v2/repositories/{namespace}/{repository}/groups``."""
534
+
535
+ namespace: str
536
+ repository: str
537
+ group_id: int | str = Field(description="Team (group) identifier")
538
+ permission: str = Field(description="Permission: read, write, or admin")
539
+ payload: dict | None = Field(description="Request body", default=None)
540
+
541
+ @field_validator("permission")
542
+ @classmethod
543
+ def validate_permission(cls, value: str) -> str:
544
+ if value not in REPOSITORY_PERMISSIONS:
545
+ raise ValueError(
546
+ f"Invalid permission {value!r}; valid: {sorted(REPOSITORY_PERMISSIONS)}"
547
+ )
548
+ return value
549
+
550
+ def model_post_init(self, _context):
551
+ self.payload = {"group_id": self.group_id, "permission": self.permission}
552
+
553
+
554
+ # --------------------------------------------------------------------------- #
555
+ # Groups (teams)
556
+ # --------------------------------------------------------------------------- #
557
+
558
+
559
+ class GroupListModel(_PaginatedModel):
560
+ """``GET /v2/orgs/{org}/groups``."""
561
+
562
+ org: str
563
+ search: str | None = Field(default=None, description="Search by group name")
564
+
565
+ def model_post_init(self, _context):
566
+ super().model_post_init(_context)
567
+ assert self.api_parameters is not None
568
+ if self.search:
569
+ self.api_parameters["search"] = self.search
570
+
571
+
572
+ class GroupCreateModel(BaseModel):
573
+ """``POST /v2/orgs/{org}/groups``."""
574
+
575
+ org: str
576
+ name: str = Field(description="Group (team) name")
577
+ description: str | None = None
578
+ payload: dict | None = Field(description="Request body", default=None)
579
+
580
+ def model_post_init(self, _context):
581
+ self.payload = {"name": self.name}
582
+ if self.description is not None:
583
+ self.payload["description"] = self.description
584
+
585
+
586
+ class GroupModel(BaseModel):
587
+ """``GET|DELETE /v2/orgs/{org}/groups/{group}``."""
588
+
589
+ org: str
590
+ group_name: str
591
+
592
+
593
+ class GroupUpdateModel(BaseModel):
594
+ """``PUT|PATCH /v2/orgs/{org}/groups/{group}``."""
595
+
596
+ org: str
597
+ group_name: str
598
+ name: str | None = None
599
+ description: str | None = None
600
+ payload: dict | None = Field(description="Request body", default=None)
601
+
602
+ @model_validator(mode="after")
603
+ def validate_any_change(self):
604
+ if self.name is None and self.description is None:
605
+ raise ValueError("Provide name and/or description to update")
606
+ return self
607
+
608
+ def model_post_init(self, _context):
609
+ self.payload = {}
610
+ if self.name is not None:
611
+ self.payload["name"] = self.name
612
+ if self.description is not None:
613
+ self.payload["description"] = self.description
614
+
615
+
616
+ class GroupMemberListModel(_PaginatedModel):
617
+ """``GET /v2/orgs/{org}/groups/{group}/members``."""
618
+
619
+ org: str
620
+ group_name: str
621
+ search: str | None = Field(default=None, description="Search by username")
622
+
623
+ def model_post_init(self, _context):
624
+ super().model_post_init(_context)
625
+ assert self.api_parameters is not None
626
+ if self.search:
627
+ self.api_parameters["search"] = self.search
628
+
629
+
630
+ class GroupMemberAddModel(BaseModel):
631
+ """``POST /v2/orgs/{org}/groups/{group}/members``."""
632
+
633
+ org: str
634
+ group_name: str
635
+ member: str = Field(description="Username to add to the group")
636
+ payload: dict | None = Field(description="Request body", default=None)
637
+
638
+ def model_post_init(self, _context):
639
+ self.payload = {"member": self.member}
640
+
641
+
642
+ class GroupMemberModel(BaseModel):
643
+ """``DELETE /v2/orgs/{org}/groups/{group}/members/{username}``."""
644
+
645
+ org: str
646
+ group_name: str
647
+ username: str
648
+
649
+
650
+ # --------------------------------------------------------------------------- #
651
+ # SCIM 2.0
652
+ # --------------------------------------------------------------------------- #
653
+
654
+
655
+ class ScimUserListModel(BaseModel):
656
+ """``GET /v2/scim/2.0/Users`` (SCIM-style 1-based pagination)."""
657
+
658
+ start_index: int | None = Field(
659
+ default=None, ge=1, description="1-based index of the first result"
660
+ )
661
+ count: int | None = Field(default=None, ge=0, description="Max results to return")
662
+ filter: str | None = Field(
663
+ default=None, description='SCIM filter, e.g. userName eq "jane"'
664
+ )
665
+ sort_by: str | None = Field(default=None, description="Attribute to sort by")
666
+ sort_order: str | None = Field(default=None, description="ascending or descending")
667
+ api_parameters: dict | None = Field(description="API parameters", default=None)
668
+
669
+ @field_validator("sort_order")
670
+ @classmethod
671
+ def validate_sort_order(cls, value: str | None) -> str | None:
672
+ if value is not None and value not in SCIM_SORT_ORDERS:
673
+ raise ValueError(
674
+ f"Invalid sort order {value!r}; valid: {sorted(SCIM_SORT_ORDERS)}"
675
+ )
676
+ return value
677
+
678
+ def model_post_init(self, _context):
679
+ self.api_parameters = {}
680
+ if self.start_index is not None:
681
+ self.api_parameters["startIndex"] = self.start_index
682
+ if self.count is not None:
683
+ self.api_parameters["count"] = self.count
684
+ if self.filter:
685
+ self.api_parameters["filter"] = self.filter
686
+ if self.sort_by:
687
+ self.api_parameters["sortBy"] = self.sort_by
688
+ if self.sort_order:
689
+ self.api_parameters["sortOrder"] = self.sort_order
690
+
691
+
692
+ SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
693
+
694
+
695
+ class ScimUserCreateModel(BaseModel):
696
+ """``POST /v2/scim/2.0/Users``."""
697
+
698
+ user_name: str = Field(description="SCIM userName (email address)")
699
+ given_name: str | None = None
700
+ family_name: str | None = None
701
+ email: str | None = Field(
702
+ default=None, description="Primary email (defaults to user_name)"
703
+ )
704
+ active: bool = Field(default=True)
705
+ payload: dict | None = Field(description="Request body", default=None)
706
+
707
+ def model_post_init(self, _context):
708
+ self.payload = {
709
+ "schemas": [SCIM_USER_SCHEMA],
710
+ "userName": self.user_name,
711
+ "active": self.active,
712
+ }
713
+ name: dict = {}
714
+ if self.given_name is not None:
715
+ name["givenName"] = self.given_name
716
+ if self.family_name is not None:
717
+ name["familyName"] = self.family_name
718
+ if name:
719
+ self.payload["name"] = name
720
+ email = self.email or self.user_name
721
+ self.payload["emails"] = [{"value": email, "primary": True}]
722
+
723
+
724
+ class ScimUserModel(BaseModel):
725
+ """``GET /v2/scim/2.0/Users/{id}``."""
726
+
727
+ user_id: str = Field(description="SCIM user identifier")
728
+
729
+
730
+ class ScimUserReplaceModel(BaseModel):
731
+ """``PUT /v2/scim/2.0/Users/{id}`` — full resource replacement."""
732
+
733
+ user_id: str = Field(description="SCIM user identifier")
734
+ user_name: str = Field(description="SCIM userName (email address)")
735
+ given_name: str | None = None
736
+ family_name: str | None = None
737
+ email: str | None = None
738
+ active: bool = Field(default=True)
739
+ payload: dict | None = Field(description="Request body", default=None)
740
+
741
+ def model_post_init(self, _context):
742
+ self.payload = {
743
+ "schemas": [SCIM_USER_SCHEMA],
744
+ "id": self.user_id,
745
+ "userName": self.user_name,
746
+ "active": self.active,
747
+ }
748
+ name: dict = {}
749
+ if self.given_name is not None:
750
+ name["givenName"] = self.given_name
751
+ if self.family_name is not None:
752
+ name["familyName"] = self.family_name
753
+ if name:
754
+ self.payload["name"] = name
755
+ email = self.email or self.user_name
756
+ self.payload["emails"] = [{"value": email, "primary": True}]