fast-feature-admin 0.0.1__tar.gz

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,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: fast-feature-admin
3
+ Version: 0.0.1
4
+ Summary: Admin JSON API and server-rendered web console for fast-feature.
5
+ Author: byunjuneseok
6
+ Author-email: byunjuneseok <byunjuneseok@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Framework :: FastAPI
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: fast-feature-core==0.0.1
19
+ Requires-Dist: fastapi>=0.110
20
+ Requires-Dist: pydantic>=2.6
21
+ Requires-Dist: jinja2>=3.1
22
+ Requires-Dist: python-multipart>=0.0.9
23
+ Requires-Python: >=3.10
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "fast-feature-admin"
3
+ version = "0.0.1"
4
+ description = "Admin JSON API and server-rendered web console for fast-feature."
5
+ license = "Apache-2.0"
6
+ requires-python = ">=3.10"
7
+ authors = [
8
+ { name = "byunjuneseok", email = "byunjuneseok@gmail.com" },
9
+ ]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Framework :: FastAPI",
13
+ "Framework :: AsyncIO",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: Apache Software License",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "fast-feature-core==0.0.1",
24
+ "fastapi>=0.110",
25
+ "pydantic>=2.6",
26
+ "jinja2>=3.1",
27
+ "python-multipart>=0.0.9",
28
+ ]
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.11.17,<0.12.0"]
32
+ build-backend = "uv_build"
33
+
34
+ [tool.uv.build-backend]
35
+ module-name = "fast_feature.admin"
36
+
37
+ [tool.uv.sources]
38
+ fast-feature-core = { workspace = true }
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .router import AdminRouter
4
+ from .service import ManagementService
5
+
6
+ __all__ = ["AdminRouter", "ManagementService"]
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .router import AdminRouter
4
+
5
+ __all__ = ["AdminRouter"]
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, HTTPException, Request, Response
9
+ from fastapi.params import Depends
10
+ from fastapi.responses import HTMLResponse, RedirectResponse
11
+ from fastapi.templating import Jinja2Templates
12
+ from pydantic import ValidationError
13
+ from starlette.datastructures import FormData
14
+
15
+ from fast_feature.core import (
16
+ FlagAlreadyExistsError,
17
+ FlagNotFoundError,
18
+ FlagRepository,
19
+ InvalidFlagError,
20
+ )
21
+
22
+ from ..schemas import FlagCreate, FlagToggle, FlagUpdate, FlagView
23
+ from ..service import ManagementService
24
+
25
+ _TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "templates"
26
+
27
+
28
+ class AdminRouter:
29
+ """Builds a pluggable admin ``APIRouter``: JSON CRUD under ``/api/flags``
30
+ plus an optional server-rendered console under ``/``.
31
+
32
+ app.include_router(AdminRouter.build(repository), prefix="/admin")
33
+
34
+ Pass ``dependencies`` (e.g. an auth guard) to protect every route.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ repository: FlagRepository,
40
+ *,
41
+ dependencies: Sequence[Depends] | None = None,
42
+ ui: bool = True,
43
+ ) -> None:
44
+ self._service = ManagementService(repository)
45
+ self._router = APIRouter(dependencies=list(dependencies or []))
46
+ self._templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
47
+ self._register_json_routes()
48
+ if ui:
49
+ self._register_ui_routes()
50
+
51
+ @property
52
+ def router(self) -> APIRouter:
53
+ return self._router
54
+
55
+ @classmethod
56
+ def build(
57
+ cls,
58
+ repository: FlagRepository,
59
+ *,
60
+ dependencies: Sequence[Depends] | None = None,
61
+ ui: bool = True,
62
+ ) -> APIRouter:
63
+ return cls(repository, dependencies=dependencies, ui=ui).router
64
+
65
+ # --- JSON API ------------------------------------------------------------
66
+
67
+ def _register_json_routes(self) -> None:
68
+ router = self._router
69
+ router.add_api_route(
70
+ "/api/flags", self._list, methods=["GET"], response_model=list[FlagView]
71
+ )
72
+ router.add_api_route(
73
+ "/api/flags", self._create, methods=["POST"], response_model=FlagView, status_code=201
74
+ )
75
+ router.add_api_route(
76
+ "/api/flags/{key}", self._get, methods=["GET"], response_model=FlagView
77
+ )
78
+ router.add_api_route(
79
+ "/api/flags/{key}", self._update, methods=["PUT"], response_model=FlagView
80
+ )
81
+ router.add_api_route(
82
+ "/api/flags/{key}", self._toggle, methods=["PATCH"], response_model=FlagView
83
+ )
84
+ router.add_api_route("/api/flags/{key}", self._delete, methods=["DELETE"], status_code=204)
85
+
86
+ async def _list(self) -> list[FlagView]:
87
+ return [FlagView.from_flag(flag) for flag in await self._service.list_flags()]
88
+
89
+ async def _create(self, body: FlagCreate) -> FlagView:
90
+ try:
91
+ flag = body.to_flag()
92
+ except InvalidFlagError as exc:
93
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
94
+ try:
95
+ created = await self._service.create_flag(flag)
96
+ except FlagAlreadyExistsError as exc:
97
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
98
+ return FlagView.from_flag(created)
99
+
100
+ async def _get(self, key: str) -> FlagView:
101
+ flag = await self._service.get_flag(key)
102
+ if flag is None:
103
+ raise HTTPException(status_code=404, detail=f"Flag {key!r} was not found")
104
+ return FlagView.from_flag(flag)
105
+
106
+ async def _update(self, key: str, body: FlagUpdate) -> FlagView:
107
+ try:
108
+ flag = body.to_flag(key)
109
+ except InvalidFlagError as exc:
110
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
111
+ try:
112
+ updated = await self._service.update_flag(flag)
113
+ except FlagNotFoundError as exc:
114
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
115
+ return FlagView.from_flag(updated)
116
+
117
+ async def _toggle(self, key: str, body: FlagToggle) -> FlagView:
118
+ try:
119
+ flag = await self._service.toggle(key, enabled=body.enabled)
120
+ except FlagNotFoundError as exc:
121
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
122
+ return FlagView.from_flag(flag)
123
+
124
+ async def _delete(self, key: str) -> Response:
125
+ try:
126
+ await self._service.delete_flag(key)
127
+ except FlagNotFoundError as exc:
128
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
129
+ return Response(status_code=204)
130
+
131
+ # --- web console ---------------------------------------------------------
132
+
133
+ def _register_ui_routes(self) -> None:
134
+ router = self._router
135
+ router.add_api_route(
136
+ "/", self._ui_list, methods=["GET"], response_class=HTMLResponse, name="admin:list"
137
+ )
138
+ router.add_api_route(
139
+ "/new", self._ui_new, methods=["GET"], response_class=HTMLResponse, name="admin:new"
140
+ )
141
+ router.add_api_route("/new", self._ui_create, methods=["POST"], name="admin:create")
142
+ router.add_api_route(
143
+ "/{key}/edit",
144
+ self._ui_edit,
145
+ methods=["GET"],
146
+ response_class=HTMLResponse,
147
+ name="admin:edit",
148
+ )
149
+ router.add_api_route("/{key}/edit", self._ui_update, methods=["POST"], name="admin:update")
150
+ router.add_api_route(
151
+ "/{key}/toggle", self._ui_toggle, methods=["POST"], name="admin:toggle"
152
+ )
153
+ router.add_api_route(
154
+ "/{key}/delete", self._ui_delete, methods=["POST"], name="admin:delete"
155
+ )
156
+
157
+ async def _ui_list(self, request: Request) -> Response:
158
+ flags = await self._service.list_flags()
159
+ return self._templates.TemplateResponse(request, "list.html", {"flags": flags})
160
+
161
+ async def _ui_new(self, request: Request) -> Response:
162
+ return self._render_form(request, flag=None, error=None)
163
+
164
+ async def _ui_create(self, request: Request) -> Response:
165
+ form = await request.form()
166
+ try:
167
+ flag = FlagCreate(**self._parse_form(form, with_key=True)).to_flag()
168
+ await self._service.create_flag(flag)
169
+ except (InvalidFlagError, FlagAlreadyExistsError, ValidationError, ValueError) as exc:
170
+ return self._render_form(request, flag=None, error=str(exc), status_code=400)
171
+ return RedirectResponse(str(request.url_for("admin:list")), status_code=303)
172
+
173
+ async def _ui_edit(self, request: Request, key: str) -> Response:
174
+ flag = await self._service.get_flag(key)
175
+ if flag is None:
176
+ raise HTTPException(status_code=404, detail=f"Flag {key!r} was not found")
177
+ return self._render_form(request, flag=flag, error=None)
178
+
179
+ async def _ui_update(self, request: Request, key: str) -> Response:
180
+ form = await request.form()
181
+ try:
182
+ flag = FlagUpdate(**self._parse_form(form, with_key=False)).to_flag(key)
183
+ await self._service.update_flag(flag)
184
+ except (InvalidFlagError, FlagNotFoundError, ValidationError, ValueError) as exc:
185
+ existing = await self._service.get_flag(key)
186
+ return self._render_form(request, flag=existing, error=str(exc), status_code=400)
187
+ return RedirectResponse(str(request.url_for("admin:list")), status_code=303)
188
+
189
+ async def _ui_toggle(self, request: Request, key: str) -> Response:
190
+ form = await request.form()
191
+ enabled = self._text(form, "enabled").lower() in ("1", "true", "on", "yes")
192
+ try:
193
+ await self._service.toggle(key, enabled=enabled)
194
+ except FlagNotFoundError as exc:
195
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
196
+ return RedirectResponse(str(request.url_for("admin:list")), status_code=303)
197
+
198
+ async def _ui_delete(self, request: Request, key: str) -> Response:
199
+ try:
200
+ await self._service.delete_flag(key)
201
+ except FlagNotFoundError as exc:
202
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
203
+ return RedirectResponse(str(request.url_for("admin:list")), status_code=303)
204
+
205
+ # --- helpers -------------------------------------------------------------
206
+
207
+ def _render_form(
208
+ self, request: Request, *, flag: Any, error: str | None, status_code: int = 200
209
+ ) -> Response:
210
+ context = {
211
+ "flag": flag,
212
+ "error": error,
213
+ "variants_json": json.dumps(flag.variants, indent=2) if flag else "",
214
+ "targeting_json": json.dumps(flag.targeting, indent=2)
215
+ if flag and flag.targeting
216
+ else "",
217
+ "metadata_json": json.dumps(flag.metadata, indent=2) if flag and flag.metadata else "",
218
+ }
219
+ return self._templates.TemplateResponse(
220
+ request, "form.html", context, status_code=status_code
221
+ )
222
+
223
+ @classmethod
224
+ def _parse_form(cls, form: FormData, *, with_key: bool) -> dict[str, Any]:
225
+ data: dict[str, Any] = {
226
+ "variants": json.loads(cls._text(form, "variants") or "{}"),
227
+ "default_variant": cls._text(form, "default_variant").strip(),
228
+ "state": cls._text(form, "state") or "ENABLED",
229
+ }
230
+ if with_key:
231
+ data["key"] = cls._text(form, "key").strip()
232
+ targeting = cls._text(form, "targeting").strip()
233
+ data["targeting"] = json.loads(targeting) if targeting else None
234
+ metadata = cls._text(form, "metadata").strip()
235
+ data["metadata"] = json.loads(metadata) if metadata else {}
236
+ return data
237
+
238
+ @staticmethod
239
+ def _text(form: FormData, name: str) -> str:
240
+ value = form.get(name)
241
+ return value if isinstance(value, str) else ""
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from .flag_create import FlagCreate
4
+ from .flag_toggle import FlagToggle
5
+ from .flag_update import FlagUpdate
6
+ from .flag_view import FlagView
7
+
8
+ __all__ = ["FlagCreate", "FlagUpdate", "FlagToggle", "FlagView"]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from fast_feature.core import Flag, FlagState
8
+
9
+
10
+ class FlagCreate(BaseModel):
11
+ key: str
12
+ variants: dict[str, Any]
13
+ default_variant: str
14
+ state: FlagState = FlagState.ENABLED
15
+ targeting: dict[str, Any] | None = None
16
+ metadata: dict[str, Any] = Field(default_factory=dict)
17
+
18
+ def to_flag(self) -> Flag:
19
+ return Flag(
20
+ key=self.key,
21
+ variants=self.variants,
22
+ default_variant=self.default_variant,
23
+ state=self.state,
24
+ targeting=self.targeting,
25
+ metadata=self.metadata,
26
+ )
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class FlagToggle(BaseModel):
7
+ enabled: bool
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from fast_feature.core import Flag, FlagState
8
+
9
+
10
+ class FlagUpdate(BaseModel):
11
+ variants: dict[str, Any]
12
+ default_variant: str
13
+ state: FlagState = FlagState.ENABLED
14
+ targeting: dict[str, Any] | None = None
15
+ metadata: dict[str, Any] = Field(default_factory=dict)
16
+
17
+ def to_flag(self, key: str) -> Flag:
18
+ return Flag(
19
+ key=key,
20
+ variants=self.variants,
21
+ default_variant=self.default_variant,
22
+ state=self.state,
23
+ targeting=self.targeting,
24
+ metadata=self.metadata,
25
+ )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from fast_feature.core import Flag, FlagState
9
+
10
+
11
+ class FlagView(BaseModel):
12
+ key: str
13
+ variants: dict[str, Any]
14
+ default_variant: str
15
+ state: FlagState
16
+ targeting: dict[str, Any] | None = None
17
+ metadata: dict[str, Any] = {}
18
+ created_at: datetime | None = None
19
+ updated_at: datetime | None = None
20
+
21
+ @classmethod
22
+ def from_flag(cls, flag: Flag) -> FlagView:
23
+ return cls(
24
+ key=flag.key,
25
+ variants=flag.variants,
26
+ default_variant=flag.default_variant,
27
+ state=flag.state,
28
+ targeting=flag.targeting,
29
+ metadata=flag.metadata,
30
+ created_at=flag.created_at,
31
+ updated_at=flag.updated_at,
32
+ )
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .management_service import ManagementService
4
+
5
+ __all__ = ["ManagementService"]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from fast_feature.core import Flag, FlagNotFoundError, FlagRepository, FlagState
4
+
5
+
6
+ class ManagementService:
7
+ """Flag administration use cases on top of a ``FlagRepository``."""
8
+
9
+ def __init__(self, repository: FlagRepository) -> None:
10
+ self._repository = repository
11
+
12
+ async def list_flags(self) -> list[Flag]:
13
+ return await self._repository.list_all()
14
+
15
+ async def get_flag(self, key: str) -> Flag | None:
16
+ return await self._repository.get(key)
17
+
18
+ async def create_flag(self, flag: Flag) -> Flag:
19
+ return await self._repository.create(flag)
20
+
21
+ async def update_flag(self, flag: Flag) -> Flag:
22
+ return await self._repository.update(flag)
23
+
24
+ async def delete_flag(self, key: str) -> None:
25
+ await self._repository.delete(key)
26
+
27
+ async def toggle(self, key: str, *, enabled: bool) -> Flag:
28
+ flag = await self._repository.get(key)
29
+ if flag is None:
30
+ raise FlagNotFoundError(key)
31
+ flag.state = FlagState.ENABLED if enabled else FlagState.DISABLED
32
+ return await self._repository.update(flag)
@@ -0,0 +1,24 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{% block title %}fast-feature admin{% endblock %}</title>
7
+ <style>
8
+ :root { color-scheme: light dark; }
9
+ body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; }
10
+ h1 a { color: inherit; text-decoration: none; }
11
+ table { width: 100%; border-collapse: collapse; }
12
+ th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #8884; }
13
+ form.inline { display: inline; }
14
+ label { display: block; margin: 0.75rem 0; }
15
+ textarea { width: 100%; min-height: 6rem; font-family: ui-monospace, monospace; }
16
+ .error { color: #b00020; font-weight: 600; }
17
+ button, a.button { cursor: pointer; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <header><h1><a href="{{ request.url_for('admin:list') }}">fast-feature</a></h1></header>
22
+ <main>{% block content %}{% endblock %}</main>
23
+ </body>
24
+ </html>
@@ -0,0 +1,29 @@
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <p><a href="{{ request.url_for('admin:list') }}">&larr; Back</a></p>
4
+ {% if error %}<p class="error">{{ error }}</p>{% endif %}
5
+ <form method="post"
6
+ action="{{ request.url_for('admin:create') if flag is none
7
+ else request.url_for('admin:update', key=flag.key) }}">
8
+ {% if flag is none %}
9
+ <label>Key <input name="key" required /></label>
10
+ {% else %}
11
+ <p><strong>Key:</strong> {{ flag.key }}</p>
12
+ {% endif %}
13
+ <label>Default variant
14
+ <input name="default_variant" value="{{ flag.default_variant if flag else '' }}" required />
15
+ </label>
16
+ <label>State
17
+ <select name="state">
18
+ <option value="ENABLED"
19
+ {{ 'selected' if not flag or flag.state.value == 'ENABLED' else '' }}>ENABLED</option>
20
+ <option value="DISABLED"
21
+ {{ 'selected' if flag and flag.state.value == 'DISABLED' else '' }}>DISABLED</option>
22
+ </select>
23
+ </label>
24
+ <label>Variants (JSON)<textarea name="variants" required>{{ variants_json }}</textarea></label>
25
+ <label>Targeting (JSON, optional)<textarea name="targeting">{{ targeting_json }}</textarea></label>
26
+ <label>Metadata (JSON, optional)<textarea name="metadata">{{ metadata_json }}</textarea></label>
27
+ <button type="submit">Save</button>
28
+ </form>
29
+ {% endblock %}
@@ -0,0 +1,38 @@
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <p><a class="button" href="{{ request.url_for('admin:new') }}">New flag</a></p>
4
+ {% if not flags %}
5
+ <p>No flags yet.</p>
6
+ {% else %}
7
+ <table>
8
+ <thead>
9
+ <tr><th>Key</th><th>State</th><th>Default</th><th>Variants</th><th>Actions</th></tr>
10
+ </thead>
11
+ <tbody>
12
+ {% for flag in flags %}
13
+ <tr>
14
+ <td>{{ flag.key }}</td>
15
+ <td>{{ flag.state.value }}</td>
16
+ <td>{{ flag.default_variant }}</td>
17
+ <td>{{ flag.variants | length }}</td>
18
+ <td>
19
+ <form class="inline" method="post"
20
+ action="{{ request.url_for('admin:toggle', key=flag.key) }}">
21
+ <input type="hidden" name="enabled"
22
+ value="{{ 'false' if flag.state.value == 'ENABLED' else 'true' }}" />
23
+ <button type="submit">
24
+ {{ 'Disable' if flag.state.value == 'ENABLED' else 'Enable' }}
25
+ </button>
26
+ </form>
27
+ <a href="{{ request.url_for('admin:edit', key=flag.key) }}">Edit</a>
28
+ <form class="inline" method="post"
29
+ action="{{ request.url_for('admin:delete', key=flag.key) }}">
30
+ <button type="submit">Delete</button>
31
+ </form>
32
+ </td>
33
+ </tr>
34
+ {% endfor %}
35
+ </tbody>
36
+ </table>
37
+ {% endif %}
38
+ {% endblock %}