feat(cli): per-app docs admin read (pic docs ls/get --app)
Fills the one per-app admin-read gap kv/files already covered. New
docs_api.rs mirrors kv_api (GET /apps/{id}/docs[/{collection}/{doc_id}],
AppDocsRead capability, DocsRepo::list/get) with the group_blobs_api docs
response shape so the CLI shares one deserialize. DocsCmd gains optional
--app/--group (mutually exclusive, like KvCmd) dispatched via
require_one_owner; new client docs_list/docs_get. Read-only by design,
matching kv/files. Pinned by a collections journey: a script writes the
app's own docs collection, the operator lists + fetches it with no script.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
186
crates/manager-core/src/docs_api.rs
Normal file
186
crates/manager-core/src/docs_api.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
//! `/api/v1/admin/apps/{id}/docs*` — read-only docs inspection.
|
||||
//!
|
||||
//! Mirrors `kv_api` / `files_api` so the `pic docs … --app` CLI (and a
|
||||
//! future dashboard tab) can browse stored documents without a script.
|
||||
//! The per-group equivalent shipped first (`group_blobs_api`); this is
|
||||
//! its per-app counterpart, filling the one admin-read gap the other
|
||||
//! data-plane services already covered. **Read-only by design** — docs
|
||||
//! writes go through `docs::create/update/delete` in scripts, which emit
|
||||
//! change events the trigger framework depends on; an admin write would
|
||||
//! bypass that, so it is deliberately out of scope (matching kv/files).
|
||||
//!
|
||||
//! Two operations:
|
||||
//! * `GET /apps/{id}/docs?collection=<c>&cursor=&limit=` — list docs in
|
||||
//! a collection (cursor-paginated).
|
||||
//! * `GET /apps/{id}/docs/{collection}/{doc_id}` — fetch one document.
|
||||
//!
|
||||
//! Capability: `AppDocsRead`, resolved against the app loaded from the
|
||||
//! path (same tier the SDK read path uses).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::docs_repo::DocsRepo;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DocsAdminState {
|
||||
pub docs: Arc<dyn DocsRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn docs_admin_router(state: DocsAdminState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/docs", get(list_docs))
|
||||
.route("/apps/{app_id}/docs/{collection}/{doc_id}", get(get_doc))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListDocsQuery {
|
||||
pub collection: String,
|
||||
#[serde(default)]
|
||||
pub cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// Mirrors the `group_blobs_api` docs shape so the CLI can share one
|
||||
/// deserialize across `--app` and `--group`.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DocEntry {
|
||||
id: String,
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ListDocsResponse {
|
||||
docs: Vec<DocEntry>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_docs(
|
||||
State(s): State<DocsAdminState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Query(q): Query<ListDocsQuery>,
|
||||
) -> Result<Json<ListDocsResponse>, DocsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppDocsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
let page = s
|
||||
.docs
|
||||
.list(
|
||||
app_id,
|
||||
&q.collection,
|
||||
q.cursor.as_deref(),
|
||||
q.limit.unwrap_or(0),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DocsApiError::Backend(e.to_string()))?;
|
||||
Ok(Json(ListDocsResponse {
|
||||
docs: page
|
||||
.docs
|
||||
.into_iter()
|
||||
.map(|d| DocEntry {
|
||||
id: d.id.to_string(),
|
||||
data: d.data,
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_doc(
|
||||
State(s): State<DocsAdminState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, collection, doc_id)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, DocsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppDocsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
let id = doc_id.parse::<Uuid>().map_err(|_| DocsApiError::NotFound)?;
|
||||
let row = s
|
||||
.docs
|
||||
.get(app_id, &collection, id)
|
||||
.await
|
||||
.map_err(|e| DocsApiError::Backend(e.to_string()))?
|
||||
.ok_or(DocsApiError::NotFound)?;
|
||||
Ok(Json(json!({ "id": row.id.to_string(), "data": row.data })))
|
||||
}
|
||||
|
||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, DocsApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| DocsApiError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or(DocsApiError::AppNotFound)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DocsApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("document not found")]
|
||||
NotFound,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("docs backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for DocsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for DocsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
use axum::http::StatusCode;
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "docs admin authz error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "docs admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ pub mod dead_letter_service;
|
||||
pub mod dead_letters_api;
|
||||
pub mod dev_email_api;
|
||||
pub mod dispatcher;
|
||||
pub mod docs_api;
|
||||
pub mod docs_filter;
|
||||
pub mod docs_repo;
|
||||
pub mod docs_service;
|
||||
@@ -188,6 +189,7 @@ pub use dead_letter_service::PostgresDeadLetterService;
|
||||
pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLettersState};
|
||||
pub use dev_email_api::{dev_emails_router, DevEmailState};
|
||||
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
|
||||
pub use docs_api::{docs_admin_router, DocsAdminState};
|
||||
pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo};
|
||||
pub use docs_service::DocsServiceImpl;
|
||||
pub use email_inbound_api::{
|
||||
|
||||
Reference in New Issue
Block a user