Files
superset2/superset/extensions/storage/api.py
2026-04-10 16:22:23 -03:00

266 lines
8.6 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
REST API for extension storage.
Provides HTTP endpoints for frontend extensions to access server-side
ephemeral storage without direct backend code.
All operations are user-scoped by default. Use `?shared=true` query param
to access shared state visible to all users.
"""
from __future__ import annotations
from typing import Any
from flask import g, request
from flask.wrappers import Response
from flask_appbuilder.api import BaseApi, expose, protect, safe
from superset.extensions import cache_manager
from superset.extensions.types import LoadedExtension
from superset.extensions.utils import get_extensions
# Key separator
SEPARATOR = ":"
# Key prefix for extension ephemeral state
KEY_PREFIX = "superset-ext"
# Default TTL: 1 hour
DEFAULT_TTL = 3600
def _build_cache_key(*parts: Any) -> str:
"""Build a namespaced cache key from parts."""
return SEPARATOR.join(str(part) for part in parts)
def _get_extension_or_404(extension_id: str) -> LoadedExtension | None:
"""Get extension by ID or return None if not found."""
extensions = get_extensions()
return extensions.get(extension_id)
def _parse_ttl(body: dict[str, Any]) -> tuple[int | None, str | None]:
"""Parse and validate TTL from request body.
Returns:
(ttl, error_message) - ttl is the parsed value, error_message is set if invalid
"""
try:
ttl = int(body.get("ttl", DEFAULT_TTL))
except (TypeError, ValueError):
return None, "Field 'ttl' must be a positive integer"
if ttl <= 0:
return None, "Field 'ttl' must be a positive integer"
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."""
allow_browser_login = True
route_base = "/api/v1/extensions/storage"
def response(self, status_code: int, **kwargs: Any) -> Response:
"""Helper method to create JSON responses."""
from flask import jsonify
return jsonify(kwargs), status_code
def response_404(self, message: str = "Not found") -> Response:
"""Helper method to create 404 responses."""
from flask import jsonify
return jsonify({"message": message}), 404
def response_400(self, message: str) -> Response:
"""Helper method to create 400 responses."""
from flask import jsonify
return jsonify({"message": message}), 400
@protect()
@safe
@expose("/ephemeral/<extension_id>/<key>", methods=("GET",))
def get_ephemeral(self, extension_id: str, key: str, **kwargs: Any) -> Response:
"""Get a value from ephemeral state.
---
get:
summary: Get a value from 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
- 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
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")
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)
@protect()
@safe
@expose("/ephemeral/<extension_id>/<key>", methods=("PUT",))
def set_ephemeral(self, extension_id: str, key: str, **kwargs: Any) -> Response:
"""Set a value in ephemeral state.
---
put:
summary: Set a value in 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
- in: query
name: shared
schema:
type: boolean
required: false
description: If true, store as shared state visible to all users
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)
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")
@protect()
@safe
@expose("/ephemeral/<extension_id>/<key>", methods=("DELETE",))
def delete_ephemeral(self, extension_id: str, key: str, **kwargs: Any) -> Response:
"""Delete a value from ephemeral state.
---
delete:
summary: Delete a value from 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
- in: query
name: shared
schema:
type: boolean
required: false
description: If true, delete from shared state
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")
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")