From 24f994b5c2313d8a4be0e039b549d3ccd0abc6c8 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" Date: Wed, 8 Apr 2026 17:05:31 -0300 Subject: [PATCH] Remove shared endpoints --- docs/developer_docs/extensions/storage.md | 32 ++- .../src/core/storage/ephemeralState.ts | 15 +- superset/extensions/storage/api.py | 206 ++++-------------- 3 files changed, 60 insertions(+), 193 deletions(-) diff --git a/docs/developer_docs/extensions/storage.md b/docs/developer_docs/extensions/storage.md index 951b0a8cdf1..1264fbb29d2 100644 --- a/docs/developer_docs/extensions/storage.md +++ b/docs/developer_docs/extensions/storage.md @@ -30,11 +30,11 @@ Each extension receives its own isolated storage namespace. When Superset loads ## Storage Tiers -| Tier | Storage Type | Context Property | Use Case | -|------|--------------|------------------|----------| -| 1 | Browser storage | `ctx.storage.local`, `ctx.storage.session` | UI state, wizard progress, draft forms | -| 2 | Server-side cache | `ctx.storage.ephemeral` | Job progress, temporary results | -| 3 | Database | `ctx.storage.persistent` | User preferences, extension config (coming soon) | +| Tier | Storage Type | Context Property | Use Case | +| ---- | ----------------- | ------------------------------------------ | ------------------------------------------------ | +| 1 | Browser storage | `ctx.storage.local`, `ctx.storage.session` | UI state, wizard progress, draft forms | +| 2 | Server-side cache | `ctx.storage.ephemeral` | Job progress, temporary results | +| 3 | Database | `ctx.storage.persistent` | User preferences, extension config (coming soon) | ## Tier 1: Local State @@ -128,7 +128,11 @@ const ctx = getContext(); await ctx.storage.ephemeral.set('job_progress', { pct: 42, status: 'running' }); // Store with custom TTL (5 minutes) -await ctx.storage.ephemeral.set('quick_cache', { results: [1, 2, 3] }, { ttl: 300 }); +await ctx.storage.ephemeral.set( + 'quick_cache', + { results: [1, 2, 3] }, + { ttl: 300 }, +); // Retrieve const progress = await ctx.storage.ephemeral.get('job_progress'); @@ -194,25 +198,17 @@ result = ctx.storage.ephemeral.shared.get('shared_result') Coming soon. -## Choosing the Right Tier - -| Need | Recommended Tier | -|------|------------------| -| UI state (sidebar collapsed, panel sizes) | `ctx.storage.local` | -| Wizard/form progress within a session | `ctx.storage.session` | -| Background job progress | `ctx.storage.ephemeral` | -| Temporary computation cache | `ctx.storage.ephemeral` | - ## Key Patterns All storage keys are automatically namespaced: -| Scope | Key Pattern | -|-------|-------------| +| Scope | Key Pattern | +| ----------- | -------------------------------------------------- | | User-scoped | `superset-ext:{extension_id}:user:{user_id}:{key}` | -| Shared | `superset-ext:{extension_id}:shared:{key}` | +| Shared | `superset-ext:{extension_id}:shared:{key}` | This ensures: + - Extensions cannot accidentally access each other's data - Users cannot see other users' data (by default) - Clean prefix-based deletion on uninstall diff --git a/superset-frontend/src/core/storage/ephemeralState.ts b/superset-frontend/src/core/storage/ephemeralState.ts index 5438155443a..6ccd14db7db 100644 --- a/superset-frontend/src/core/storage/ephemeralState.ts +++ b/superset-frontend/src/core/storage/ephemeralState.ts @@ -28,13 +28,12 @@ const DEFAULT_TTL = 3600; export function createEphemeralState( extensionId: string, ): typeof StorageTypes.ephemeralState { - const buildUrl = (key: string, isShared: boolean): string => { + const buildUrl = (key: string, shared?: boolean): string => { const basePath = '/api/v1/extensions/storage/ephemeral'; const encodedId = encodeURIComponent(extensionId); const encodedKey = encodeURIComponent(key); - return isShared - ? `${basePath}/shared/${encodedId}/${encodedKey}` - : `${basePath}/${encodedId}/${encodedKey}`; + const url = `${basePath}/${encodedId}/${encodedKey}`; + return shared ? `${url}?shared=true` : url; }; const shared: StorageTypes.ephemeralState.EphemeralStateAccessor = { @@ -63,9 +62,7 @@ export function createEphemeralState( return { DEFAULT_TTL, get: async (key: string) => { - const response = await SupersetClient.get({ - endpoint: buildUrl(key, false), - }); + const response = await SupersetClient.get({ endpoint: buildUrl(key) }); return response.json?.result ?? null; }, set: async ( @@ -74,13 +71,13 @@ export function createEphemeralState( options?: StorageTypes.ephemeralState.SetOptions, ) => { await SupersetClient.put({ - endpoint: buildUrl(key, false), + endpoint: buildUrl(key), body: JSON.stringify({ value, ttl: options?.ttl ?? DEFAULT_TTL }), headers: { 'Content-Type': 'application/json' }, }); }, remove: async (key: string) => { - await SupersetClient.delete({ endpoint: buildUrl(key, false) }); + await SupersetClient.delete({ endpoint: buildUrl(key) }); }, shared, }; diff --git a/superset/extensions/storage/api.py b/superset/extensions/storage/api.py index 6979aeb1312..6213be4abd0 100644 --- a/superset/extensions/storage/api.py +++ b/superset/extensions/storage/api.py @@ -21,8 +21,8 @@ REST API for extension storage. Provides HTTP endpoints for frontend extensions to access server-side ephemeral storage without direct backend code. -By default, all operations are user-scoped (private to the current user). -Use the /shared/ endpoints to access state visible to all users. +All operations are user-scoped by default. Use `?shared=true` query param +to access shared state visible to all users. """ from __future__ import annotations @@ -73,6 +73,14 @@ def _parse_ttl(body: dict[str, Any]) -> tuple[int | None, str | None]: return ttl, None +def _build_storage_key(extension_id: str, key: str, shared: bool) -> str: + """Build the cache key based on scope (user or shared).""" + if shared: + return _build_cache_key(KEY_PREFIX, extension_id, "shared", key) + user_id = g.user.id + return _build_cache_key(KEY_PREFIX, extension_id, "user", user_id, key) + + class ExtensionStorageRestApi(BaseApi): """REST API for extension ephemeral state storage.""" @@ -97,18 +105,14 @@ class ExtensionStorageRestApi(BaseApi): return jsonify({"message": message}), 400 - # ========================================================================= - # User-Scoped Ephemeral State Endpoints (Default) - # ========================================================================= - @protect() @safe @expose("/ephemeral//", methods=("GET",)) def get_ephemeral(self, extension_id: str, key: str, **kwargs: Any) -> Response: - """Get a value from user-scoped ephemeral state. + """Get a value from ephemeral state. --- get: - summary: Get a value from user-scoped ephemeral state (default) + summary: Get a value from ephemeral state parameters: - in: path name: extension_id @@ -122,6 +126,12 @@ class ExtensionStorageRestApi(BaseApi): type: string required: true description: Storage key + - in: query + name: shared + schema: + type: boolean + required: false + description: If true, read from shared state visible to all users responses: 200: description: Value retrieved successfully @@ -139,8 +149,8 @@ class ExtensionStorageRestApi(BaseApi): if not extension: return self.response_404("Extension not found") - user_id = g.user.id - cache_key = _build_cache_key(KEY_PREFIX, extension_id, "user", user_id, key) + shared = request.args.get("shared", "false").lower() == "true" + cache_key = _build_storage_key(extension_id, key, shared) value = cache_manager.extension_ephemeral_state_cache.get(cache_key) return self.response(200, result=value) @@ -149,10 +159,10 @@ class ExtensionStorageRestApi(BaseApi): @safe @expose("/ephemeral//", methods=("PUT",)) def set_ephemeral(self, extension_id: str, key: str, **kwargs: Any) -> Response: - """Set a value in user-scoped ephemeral state. + """Set a value in ephemeral state. --- put: - summary: Set a value in user-scoped ephemeral state (default) + summary: Set a value in ephemeral state parameters: - in: path name: extension_id @@ -166,6 +176,12 @@ class ExtensionStorageRestApi(BaseApi): type: string required: true description: Storage key + - in: query + name: shared + schema: + type: boolean + required: false + description: If true, store as shared state visible to all users requestBody: required: true content: @@ -199,8 +215,8 @@ class ExtensionStorageRestApi(BaseApi): if error: return self.response_400(error) - user_id = g.user.id - cache_key = _build_cache_key(KEY_PREFIX, extension_id, "user", user_id, key) + shared = request.args.get("shared", "false").lower() == "true" + cache_key = _build_storage_key(extension_id, key, shared) cache_manager.extension_ephemeral_state_cache.set(cache_key, value, timeout=ttl) return self.response(200, message="Value stored successfully") @@ -209,10 +225,10 @@ class ExtensionStorageRestApi(BaseApi): @safe @expose("/ephemeral//", methods=("DELETE",)) def delete_ephemeral(self, extension_id: str, key: str, **kwargs: Any) -> Response: - """Delete a value from user-scoped ephemeral state. + """Delete a value from ephemeral state. --- delete: - summary: Delete a value from user-scoped ephemeral state (default) + summary: Delete a value from ephemeral state parameters: - in: path name: extension_id @@ -226,6 +242,12 @@ class ExtensionStorageRestApi(BaseApi): type: string required: true description: Storage key + - in: query + name: shared + schema: + type: boolean + required: false + description: If true, delete from shared state responses: 200: description: Value deleted successfully @@ -236,156 +258,8 @@ class ExtensionStorageRestApi(BaseApi): if not extension: return self.response_404("Extension not found") - user_id = g.user.id - cache_key = _build_cache_key(KEY_PREFIX, extension_id, "user", user_id, key) - cache_manager.extension_ephemeral_state_cache.delete(cache_key) - - return self.response(200, message="Value deleted successfully") - - # ========================================================================= - # Shared (Global) Ephemeral State Endpoints - # ========================================================================= - - @protect() - @safe - @expose("/ephemeral/shared//", methods=("GET",)) - def get_ephemeral_shared( - self, extension_id: str, key: str, **kwargs: Any - ) -> Response: - """Get a value from shared ephemeral state. - --- - get: - summary: Get a value from shared (global) ephemeral state - parameters: - - in: path - name: extension_id - schema: - type: string - required: true - description: Extension ID (publisher.name) - - in: path - name: key - schema: - type: string - required: true - description: Storage key - responses: - 200: - description: Value retrieved successfully - content: - application/json: - schema: - type: object - properties: - result: - description: The stored value - 404: - description: Extension not found - """ - extension = _get_extension_or_404(extension_id) - if not extension: - return self.response_404("Extension not found") - - cache_key = _build_cache_key(KEY_PREFIX, extension_id, "shared", key) - value = cache_manager.extension_ephemeral_state_cache.get(cache_key) - - return self.response(200, result=value) - - @protect() - @safe - @expose("/ephemeral/shared//", methods=("PUT",)) - def set_ephemeral_shared( - self, extension_id: str, key: str, **kwargs: Any - ) -> Response: - """Set a value in shared ephemeral state. - --- - put: - summary: Set a value in shared (global) ephemeral state - parameters: - - in: path - name: extension_id - schema: - type: string - required: true - description: Extension ID (publisher.name) - - in: path - name: key - schema: - type: string - required: true - description: Storage key - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - value: - description: The value to store - ttl: - type: integer - description: Time-to-live in seconds (default 3600) - responses: - 200: - description: Value stored successfully - 400: - description: Invalid request body - 404: - description: Extension not found - """ - extension = _get_extension_or_404(extension_id) - if not extension: - return self.response_404("Extension not found") - - body = request.get_json(silent=True) or {} - if "value" not in body: - return self.response_400("Request body must contain 'value' field") - - value = body["value"] - ttl, error = _parse_ttl(body) - if error: - return self.response_400(error) - - cache_key = _build_cache_key(KEY_PREFIX, extension_id, "shared", key) - cache_manager.extension_ephemeral_state_cache.set(cache_key, value, timeout=ttl) - - return self.response(200, message="Value stored successfully") - - @protect() - @safe - @expose("/ephemeral/shared//", methods=("DELETE",)) - def delete_ephemeral_shared( - self, extension_id: str, key: str, **kwargs: Any - ) -> Response: - """Delete a value from shared ephemeral state. - --- - delete: - summary: Delete a value from shared (global) ephemeral state - parameters: - - in: path - name: extension_id - schema: - type: string - required: true - description: Extension ID (publisher.name) - - in: path - name: key - schema: - type: string - required: true - description: Storage key - responses: - 200: - description: Value deleted successfully - 404: - description: Extension not found - """ - extension = _get_extension_or_404(extension_id) - if not extension: - return self.response_404("Extension not found") - - cache_key = _build_cache_key(KEY_PREFIX, extension_id, "shared", key) + shared = request.args.get("shared", "false").lower() == "true" + cache_key = _build_storage_key(extension_id, key, shared) cache_manager.extension_ephemeral_state_cache.delete(cache_key) return self.response(200, message="Value deleted successfully")