Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions cli/src/api_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,57 @@ pub struct StopDeploymentResponse {
pub status: DeploymentStatus,
}

// ========================================================================
// Registry DTOs
// ========================================================================

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryStackItem {
pub name: String,
pub description: Option<String>,
pub websocket_url: String,
pub entities: Vec<String>,
#[serde(default)]
pub visibility: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistrySchemaResponse {
pub name: String,
pub websocket_url: String,
pub description: Option<String>,
pub schema: StackSchema,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StackSchema {
pub stack_name: String,
pub entities: Vec<EntitySchema>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntitySchema {
pub name: String,
pub primary_keys: Vec<String>,
pub fields: Vec<FieldSchema>,
pub views: Vec<ViewSchema>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldSchema {
pub path: String,
pub rust_type: String,
pub nullable: bool,
pub section: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewSchema {
pub id: String,
pub mode: String,
pub pipeline: Vec<serde_json::Value>,
}

impl ApiClient {
pub fn new() -> Result<Self> {
let base_url =
Expand Down Expand Up @@ -455,6 +506,62 @@ impl ApiClient {
Ok(specs.into_iter().find(|s| s.name == name))
}

// ========================================================================
// Registry endpoints (public, no auth required)
// ========================================================================

/// List all public stacks in the registry (no auth required)
pub fn list_registry(&self) -> Result<Vec<RegistryStackItem>> {
let response = self
.client
.get(format!("{}/api/registry", self.base_url))
.send()
.context("Failed to send registry list request")?;

Self::handle_response(response)
}

/// Get a public stack's info from the registry (no auth required)
#[allow(dead_code)]
pub fn get_registry_stack(&self, name: &str) -> Result<RegistryStackItem> {
let response = self
.client
.get(format!("{}/api/registry/{}", self.base_url, name))
.send()
.context("Failed to send registry get request")?;

Self::handle_response(response)
}

/// Get full schema for a public stack (no auth required)
pub fn get_registry_schema(&self, name: &str) -> Result<RegistrySchemaResponse> {
let response = self
.client
.get(format!("{}/api/registry/{}/schema", self.base_url, name))
.send()
.context("Failed to send registry schema request")?;

Self::handle_response(response)
}

// ========================================================================
// Authenticated schema endpoints
// ========================================================================

/// Get schema for user's own spec (requires auth)
pub fn get_spec_schema(&self, spec_id: i32) -> Result<RegistrySchemaResponse> {
let api_key = self.require_api_key()?;

let response = self
.client
.get(format!("{}/api/specs/{}/schema", self.base_url, spec_id))
.bearer_auth(api_key)
.send()
.context("Failed to send spec schema request")?;

Self::handle_response(response)
}

// ========================================================================
// Build endpoints
// ========================================================================
Expand Down
278 changes: 278 additions & 0 deletions cli/src/commands/explore.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
use anyhow::Result;
use colored::Colorize;

use crate::api_client::{ApiClient, EntitySchema, RegistryStackItem, DEFAULT_DOMAIN_SUFFIX};

pub fn list(json: bool) -> Result<()> {
let client = ApiClient::new()?;
let registry_stacks = client.list_registry()?;
let user_stacks = client.list_specs().ok();
let user_deployments = if user_stacks.is_some() {
client.list_deployments(100).ok()
} else {
None
};

if json {
#[derive(serde::Serialize)]
struct ExploreListOutput {
registry: Vec<RegistryStackItem>,
#[serde(skip_serializing_if = "Option::is_none")]
user_stacks: Option<Vec<UserStackItem>>,
}

#[derive(serde::Serialize)]
struct UserStackItem {
name: String,
entity_name: String,
websocket_url: String,
status: Option<String>,
}

let user_items = user_stacks.map(|specs| {
let deployment_map: std::collections::HashMap<i32, _> = user_deployments
.unwrap_or_default()
.into_iter()
.map(|d| (d.spec_id, d))
.collect();

specs
.into_iter()
.map(|spec| {
let deployment = deployment_map.get(&spec.id);
UserStackItem {
name: spec.name.clone(),
entity_name: spec.entity_name.clone(),
websocket_url: spec.websocket_url(DEFAULT_DOMAIN_SUFFIX),
status: deployment.map(|d| d.status.to_string()),
}
})
.collect()
});

let output = ExploreListOutput {
registry: registry_stacks,
user_stacks: user_items,
};

println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}

if !registry_stacks.is_empty() {
println!("\n{}", "Public Registry".bold());
println!("{}", "-".repeat(60).dimmed());

for stack in &registry_stacks {
println!(
" {} {}",
stack.name.green().bold(),
stack.websocket_url.cyan()
);
if let Some(desc) = &stack.description {
println!(" {}", desc.dimmed());
}
println!(" Entities: {}", stack.entities.join(", "));
println!();
}
}

if let Some(specs) = user_stacks {
if !specs.is_empty() {
let deployment_map: std::collections::HashMap<i32, _> = user_deployments
.unwrap_or_default()
.into_iter()
.map(|d| (d.spec_id, d))
.collect();

println!("{}", "Your Stacks".bold());
println!("{}", "-".repeat(60).dimmed());

for spec in &specs {
let deployment = deployment_map.get(&spec.id);
let status = deployment
.map(|d| d.status.to_string())
.unwrap_or_else(|| "-".to_string());

println!(
" {} {} [{}]",
spec.name.green().bold(),
spec.websocket_url(DEFAULT_DOMAIN_SUFFIX).cyan(),
status,
);
}
println!();
}
}

if registry_stacks.is_empty() {
println!("{}", "No stacks found in registry.".yellow());
}

println!(
"{}",
"Tip: Run `hs explore <name>` for detailed entity info".dimmed()
);

Ok(())
}

pub fn show(name: &str, entity: Option<&str>, json: bool) -> Result<()> {
let client = ApiClient::new()?;
let schema_response = match client.get_registry_schema(name) {
Ok(schema) => schema,
Err(_) => {
let spec = client.get_spec_by_name(name)?.ok_or_else(|| {
anyhow::anyhow!(
"Stack '{}' not found. Run `hs explore` to see available stacks.",
name
)
})?;
client.get_spec_schema(spec.id)?
}
};

if let Some(entity_name) = entity {
let entity_schema = schema_response
.schema
.entities
.iter()
.find(|e| e.name.eq_ignore_ascii_case(entity_name))
.ok_or_else(|| {
anyhow::anyhow!(
"Entity '{}' not found in stack '{}'. Available entities: {}",
entity_name,
name,
schema_response
.schema
.entities
.iter()
.map(|e| e.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;

if json {
println!("{}", serde_json::to_string_pretty(&entity_schema)?);
return Ok(());
}

print_entity_detail(entity_schema);
return Ok(());
}

if json {
println!("{}", serde_json::to_string_pretty(&schema_response)?);
return Ok(());
}

println!(
"\n{} {}",
"Stack:".bold(),
schema_response.name.green().bold()
);
println!(" URL: {}", schema_response.websocket_url.cyan());
if let Some(desc) = &schema_response.description {
println!(" {}", desc.dimmed());
}

println!("\n{}", "Entities".bold());
println!("{}", "-".repeat(60).dimmed());

for entity_schema in &schema_response.schema.entities {
let view_names: Vec<&str> = entity_schema
.views
.iter()
.map(|v| v.id.split('/').next_back().unwrap_or(v.id.as_str()))
.collect();

println!(
" {} {} views ({})",
entity_schema.name.green().bold(),
entity_schema.views.len(),
view_names.join(", ")
);
println!(
" Primary key: {}",
entity_schema.primary_keys.join(", ").cyan()
);
println!(" Fields: {}", entity_schema.fields.len());
}

println!();
println!(
"{}",
format!("Tip: Run `hs explore {} <entity>` for field details", name).dimmed()
);

Ok(())
}

fn print_entity_detail(entity: &EntitySchema) {
println!("\n{} {}", "Entity:".bold(), entity.name.green().bold());
println!(" Primary key: {}", entity.primary_keys.join(", ").cyan());

println!("\n{}", "Fields".bold());
println!("{}", "-".repeat(70).dimmed());

let mut current_section = String::new();
for field in &entity.fields {
if field.section != current_section {
current_section = field.section.clone();
println!(" {} {}", "-".dimmed(), current_section.bold());
}

let nullable_str = if field.nullable { "?" } else { "" };
println!(
" {:<40} {}{}",
field.path,
field.rust_type.cyan(),
nullable_str.dimmed()
);
}

println!("\n{}", "Views".bold());
println!("{}", "-".repeat(70).dimmed());

for view in &entity.views {
let view_short = view.id.split('/').next_back().unwrap_or(view.id.as_str());
let pipeline_str = if view.pipeline.is_empty() {
String::new()
} else {
let steps: Vec<String> = view
.pipeline
.iter()
.map(|p| {
if let Some(sort) = p.get("Sort") {
let key = sort
.get("key")
.and_then(|k| k.get("segments"))
.and_then(|s| s.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(".")
})
.unwrap_or_default();
let order = sort.get("order").and_then(|o| o.as_str()).unwrap_or("asc");
format!("sort by {} {}", key, order)
} else {
p.to_string()
}
})
.collect();
format!(" ({})", steps.join(", "))
};

println!(
" {:<20} {:<8}{}",
view_short.green(),
view.mode,
pipeline_str.dimmed()
);
}

println!();
}
Loading