From 99314479956c3b2ca80c899b3dbe3f129d7ca6f5 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:14:07 +0000 Subject: [PATCH 01/13] feat: add URL resolver support with configurable HTTP methods and extraction paths - Introduced `UrlResolverConfig` struct to define URL resolution parameters. - Added `HttpMethod` enum for specifying GET and POST methods. - Enhanced `ResolverType` to include URL resolvers. - Implemented URL resolution logic in the code generation and runtime, allowing for batch processing and JSON path extraction from responses. - Updated parsing logic to support new `url_resolve` attribute in macros. --- hyperstack-macros/src/ast/types.rs | 21 +++ .../src/codegen/vixen_runtime.rs | 126 ++++++++++++++++- hyperstack-macros/src/parse/attributes.rs | 133 +++++++++++++++++- .../src/stream_spec/ast_writer.rs | 5 +- hyperstack-macros/src/stream_spec/entity.rs | 24 +++- .../src/stream_spec/proto_struct.rs | 24 ++++ hyperstack-macros/src/stream_spec/sections.rs | 27 ++++ interpreter/src/ast.rs | 21 +++ interpreter/src/resolvers.rs | 131 +++++++++++++++++ interpreter/src/vm.rs | 5 +- 10 files changed, 509 insertions(+), 8 deletions(-) diff --git a/hyperstack-macros/src/ast/types.rs b/hyperstack-macros/src/ast/types.rs index c4ef465..1ef3441 100644 --- a/hyperstack-macros/src/ast/types.rs +++ b/hyperstack-macros/src/ast/types.rs @@ -251,6 +251,27 @@ pub struct ComputedFieldSpec { #[serde(rename_all = "lowercase")] pub enum ResolverType { Token, + Url(UrlResolverConfig), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +#[serde(rename_all = "lowercase")] +pub enum HttpMethod { + #[default] + Get, + Post, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct UrlResolverConfig { + /// Field path to get the URL from (e.g., "info.uri") + pub url_path: String, + /// HTTP method to use (default: GET) + #[serde(default)] + pub method: HttpMethod, + /// JSON path to extract from response (None = full payload) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extract_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/hyperstack-macros/src/codegen/vixen_runtime.rs b/hyperstack-macros/src/codegen/vixen_runtime.rs index ead3aea..980bd71 100644 --- a/hyperstack-macros/src/codegen/vixen_runtime.rs +++ b/hyperstack-macros/src/codegen/vixen_runtime.rs @@ -347,13 +347,18 @@ pub fn generate_vm_handler( }; let mut token_requests = Vec::new(); + let mut url_requests = Vec::new(); let mut other_requests = Vec::new(); for request in requests { - match request.resolver { + match &request.resolver { hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Token => { token_requests.push(request) } + hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Url(_) => { + url_requests.push(request) + } + #[allow(unreachable_patterns)] _ => other_requests.push(request), } } @@ -429,6 +434,62 @@ pub fn generate_vm_handler( } } + // Process URL resolver requests + if !url_requests.is_empty() { + let url_client = hyperstack::runtime::hyperstack_interpreter::resolvers::UrlResolverClient::new(); + + for request in url_requests { + if let hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Url(config) = &request.resolver { + // Get the URL from the input value + let url = match &request.input { + hyperstack::runtime::serde_json::Value::String(s) => s.clone(), + _ => { + hyperstack::runtime::tracing::warn!( + "URL resolver input is not a string: {:?}", + request.input + ); + let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); + vm.restore_resolver_requests(vec![request]); + continue; + } + }; + + if url.is_empty() { + continue; + } + + match url_client.resolve_with_extract(&url, &config.method, config.extract_path.as_deref()).await { + Ok(resolved_value) => { + let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); + match vm.apply_resolver_result( + self.bytecode.as_ref(), + &request.cache_key, + resolved_value, + ) { + Ok(mut new_mutations) => { + mutations.append(&mut new_mutations); + } + Err(err) => { + hyperstack::runtime::tracing::warn!( + url = %url, + "Failed to apply URL resolver result: {}", + err + ); + } + } + } + Err(err) => { + hyperstack::runtime::tracing::warn!( + url = %url, + "URL resolver request failed: {}", + err + ); + } + } + } + } + } + if !other_requests.is_empty() { let other_count = other_requests.len(); let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); @@ -1255,13 +1316,18 @@ pub fn generate_vm_handler_struct() -> TokenStream { }; let mut token_requests = Vec::new(); + let mut url_requests = Vec::new(); let mut other_requests = Vec::new(); for request in requests { - match request.resolver { + match &request.resolver { hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Token => { token_requests.push(request) } + hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Url(_) => { + url_requests.push(request) + } + #[allow(unreachable_patterns)] _ => other_requests.push(request), } } @@ -1337,6 +1403,62 @@ pub fn generate_vm_handler_struct() -> TokenStream { } } + // Process URL resolver requests + if !url_requests.is_empty() { + let url_client = hyperstack::runtime::hyperstack_interpreter::resolvers::UrlResolverClient::new(); + + for request in url_requests { + if let hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Url(config) = &request.resolver { + // Get the URL from the input value + let url = match &request.input { + hyperstack::runtime::serde_json::Value::String(s) => s.clone(), + _ => { + hyperstack::runtime::tracing::warn!( + "URL resolver input is not a string: {:?}", + request.input + ); + let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); + vm.restore_resolver_requests(vec![request]); + continue; + } + }; + + if url.is_empty() { + continue; + } + + match url_client.resolve_with_extract(&url, &config.method, config.extract_path.as_deref()).await { + Ok(resolved_value) => { + let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); + match vm.apply_resolver_result( + self.bytecode.as_ref(), + &request.cache_key, + resolved_value, + ) { + Ok(mut new_mutations) => { + mutations.append(&mut new_mutations); + } + Err(err) => { + hyperstack::runtime::tracing::warn!( + url = %url, + "Failed to apply URL resolver result: {}", + err + ); + } + } + } + Err(err) => { + hyperstack::runtime::tracing::warn!( + url = %url, + "URL resolver request failed: {}", + err + ); + } + } + } + } + } + if !other_requests.is_empty() { let other_count = other_requests.len(); let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/hyperstack-macros/src/parse/attributes.rs b/hyperstack-macros/src/parse/attributes.rs index 2419669..5918815 100644 --- a/hyperstack-macros/src/parse/attributes.rs +++ b/hyperstack-macros/src/parse/attributes.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use syn::parse::{Parse, ParseStream}; use syn::{Attribute, Path, Token}; -use crate::ast::ResolverType; +use crate::ast::{HttpMethod, ResolverType}; #[derive(Debug, Clone)] pub struct RegisterFromSpec { @@ -1147,6 +1147,137 @@ pub fn parse_computed_attribute( })) } +// ============================================================================ +// URL Resolve Macro - Fetch and parse data from external URLs +// ============================================================================ + +#[derive(Debug, Clone)] +pub struct UrlResolveAttribute { + /// Field path to get the URL from (e.g., "info.uri") + pub url_path: String, + /// HTTP method to use (default: GET) + pub method: HttpMethod, + /// JSON path to extract from response (None = full payload) + pub extract_path: Option, + /// Strategy for updates (SetOnce or LastWrite) + pub strategy: String, + /// Target field name + pub target_field_name: String, +} + +struct UrlResolveAttributeArgs { + url: Option, + method: Option, + extract: Option, + strategy: Option, +} + +impl Parse for UrlResolveAttributeArgs { + fn parse(input: ParseStream) -> syn::Result { + let mut url = None; + let mut method = None; + let mut extract = None; + let mut strategy = None; + + while !input.is_empty() { + let ident: syn::Ident = input.parse()?; + let ident_str = ident.to_string(); + + input.parse::()?; + + if ident_str == "url" { + // Parse as path (e.g., info.uri) and convert to string + let path: Path = input.parse()?; + url = Some( + path.segments + .iter() + .map(|seg| seg.ident.to_string()) + .collect::>() + .join("."), + ); + } else if ident_str == "method" { + method = Some(input.parse()?); + } else if ident_str == "extract" { + let lit: syn::LitStr = input.parse()?; + extract = Some(lit.value()); + } else if ident_str == "strategy" { + strategy = Some(input.parse()?); + } else { + return Err(syn::Error::new( + ident.span(), + format!("Unknown url_resolve attribute argument: {}", ident_str), + )); + } + + if !input.is_empty() { + input.parse::()?; + } + } + + Ok(UrlResolveAttributeArgs { + url, + method, + extract, + strategy, + }) + } +} + +/// Parse #[url_resolve(url = info.uri, method = GET, extract = "image", strategy = SetOnce)] attribute +pub fn parse_url_resolve_attribute( + attr: &Attribute, + target_field_name: &str, +) -> syn::Result> { + if !attr.path().is_ident("url_resolve") { + return Ok(None); + } + + let args: UrlResolveAttributeArgs = attr.parse_args()?; + + let url_path = args.url.ok_or_else(|| { + syn::Error::new_spanned(attr, "#[url_resolve] requires 'url' parameter specifying the field path to get the URL from") + })?; + + let method = if let Some(method_ident) = args.method { + match method_ident.to_string().to_lowercase().as_str() { + "get" => HttpMethod::Get, + "post" => HttpMethod::Post, + _ => { + return Err(syn::Error::new_spanned( + method_ident, + "Invalid HTTP method. Only 'GET' or 'POST' are supported.", + )); + } + } + } else { + HttpMethod::Get + }; + + let strategy = args + .strategy + .map(|s| s.to_string()) + .unwrap_or_else(|| "SetOnce".to_string()); + + // Validate strategy + if strategy != "SetOnce" && strategy != "LastWrite" { + return Err(syn::Error::new_spanned( + attr, + format!( + "Invalid strategy '{}' for #[url_resolve]. Only 'SetOnce' or 'LastWrite' are allowed.", + strategy + ), + )); + } + + Ok(Some(UrlResolveAttribute { + url_path, + method, + extract_path: args.extract, + strategy, + target_field_name: target_field_name.to_string(), + })) +} + pub fn has_entity_attribute(attrs: &[Attribute]) -> bool { attrs.iter().any(|attr| attr.path().is_ident("entity")) } diff --git a/hyperstack-macros/src/stream_spec/ast_writer.rs b/hyperstack-macros/src/stream_spec/ast_writer.rs index ed53dec..18b9832 100644 --- a/hyperstack-macros/src/stream_spec/ast_writer.rs +++ b/hyperstack-macros/src/stream_spec/ast_writer.rs @@ -232,9 +232,10 @@ fn parse_resolve_strategy(strategy: &str) -> ResolveStrategy { } } -fn resolver_type_key(resolver: &ResolverType) -> &'static str { +fn resolver_type_key(resolver: &ResolverType) -> String { match resolver { - ResolverType::Token => "token", + ResolverType::Token => "token".to_string(), + ResolverType::Url(config) => format!("url:{}", config.url_path), } } diff --git a/hyperstack-macros/src/stream_spec/entity.rs b/hyperstack-macros/src/stream_spec/entity.rs index 46212e1..1b0a62c 100644 --- a/hyperstack-macros/src/stream_spec/entity.rs +++ b/hyperstack-macros/src/stream_spec/entity.rs @@ -18,7 +18,7 @@ use std::collections::{HashMap, HashSet}; use quote::{format_ident, quote}; use syn::{Fields, GenericArgument, ItemStruct, PathArguments, Type}; -use crate::ast::{EntitySection, FieldTypeInfo, ResolverHook, ResolverType}; +use crate::ast::{EntitySection, FieldTypeInfo, ResolverHook, ResolverType, UrlResolverConfig}; use crate::codegen; use crate::event_type_helpers::IdlLookup; use crate::parse; @@ -454,6 +454,28 @@ pub fn process_entity_struct_with_idl( computed_attr.expression.clone(), field_type.clone(), )); + } else if let Ok(Some(url_resolve_attr)) = + parse::parse_url_resolve_attribute(attr, &field_name.to_string()) + { + has_attrs = true; + + state_fields.push(quote! { + pub #field_name: #field_type + }); + + // Create URL resolver spec + resolve_specs.push(parse::ResolveSpec { + resolver: ResolverType::Url(UrlResolverConfig { + url_path: url_resolve_attr.url_path.clone(), + method: url_resolve_attr.method.clone(), + extract_path: url_resolve_attr.extract_path.clone(), + }), + from: Some(url_resolve_attr.url_path), + address: None, + extract: url_resolve_attr.extract_path, + target_field_name: url_resolve_attr.target_field_name, + strategy: url_resolve_attr.strategy, + }); } } diff --git a/hyperstack-macros/src/stream_spec/proto_struct.rs b/hyperstack-macros/src/stream_spec/proto_struct.rs index 842f106..6302e71 100644 --- a/hyperstack-macros/src/stream_spec/proto_struct.rs +++ b/hyperstack-macros/src/stream_spec/proto_struct.rs @@ -448,6 +448,30 @@ pub fn process_struct_with_context( crate::ast::ResolverType::Token => quote! { hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Token }, + crate::ast::ResolverType::Url(config) => { + let url_path = &config.url_path; + let method_code = match config.method { + crate::ast::HttpMethod::Get => quote! { + hyperstack::runtime::hyperstack_interpreter::ast::HttpMethod::Get + }, + crate::ast::HttpMethod::Post => quote! { + hyperstack::runtime::hyperstack_interpreter::ast::HttpMethod::Post + }, + }; + let extract_path_code = match &config.extract_path { + Some(path) => quote! { Some(#path.to_string()) }, + None => quote! { None }, + }; + quote! { + hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Url( + hyperstack::runtime::hyperstack_interpreter::ast::UrlResolverConfig { + url_path: #url_path.to_string(), + method: #method_code, + extract_path: #extract_path_code, + } + ) + } + }, }; let strategy_code = match strategy.as_str() { "LastWrite" => quote! { diff --git a/hyperstack-macros/src/stream_spec/sections.rs b/hyperstack-macros/src/stream_spec/sections.rs index 089a655..fb5ee50 100644 --- a/hyperstack-macros/src/stream_spec/sections.rs +++ b/hyperstack-macros/src/stream_spec/sections.rs @@ -653,6 +653,33 @@ pub fn process_nested_struct( target_field_name, strategy: resolve_attr.strategy, }); + } else if let Ok(Some(url_resolve_attr)) = + parse::parse_url_resolve_attribute(attr, &field_name.to_string()) + { + let mut target_field_name = url_resolve_attr.target_field_name.clone(); + if !target_field_name.contains('.') { + target_field_name = format!("{}.{}", section_name, target_field_name); + } + + // Qualify the url_path with section name if needed + let url_path = if url_resolve_attr.url_path.contains('.') { + url_resolve_attr.url_path.clone() + } else { + format!("{}.{}", section_name, url_resolve_attr.url_path) + }; + + resolve_specs.push(parse::ResolveSpec { + resolver: crate::ast::ResolverType::Url(crate::ast::UrlResolverConfig { + url_path: url_path.clone(), + method: url_resolve_attr.method.clone(), + extract_path: url_resolve_attr.extract_path.clone(), + }), + from: Some(url_path), + address: None, + extract: url_resolve_attr.extract_path, + target_field_name, + strategy: url_resolve_attr.strategy, + }); } } } diff --git a/interpreter/src/ast.rs b/interpreter/src/ast.rs index 7cd0da4..5036a9a 100644 --- a/interpreter/src/ast.rs +++ b/interpreter/src/ast.rs @@ -367,6 +367,27 @@ pub struct ComputedFieldSpec { #[serde(rename_all = "lowercase")] pub enum ResolverType { Token, + Url(UrlResolverConfig), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +#[serde(rename_all = "lowercase")] +pub enum HttpMethod { + #[default] + Get, + Post, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct UrlResolverConfig { + /// Field path to get the URL from (e.g., "info.uri") + pub url_path: String, + /// HTTP method to use (default: GET) + #[serde(default)] + pub method: HttpMethod, + /// JSON path to extract from response (None = full payload) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extract_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/interpreter/src/resolvers.rs b/interpreter/src/resolvers.rs index 3e5f05e..270977c 100644 --- a/interpreter/src/resolvers.rs +++ b/interpreter/src/resolvers.rs @@ -494,6 +494,137 @@ impl TokenMetadataResolverClient { } } +// ============================================================================ +// URL Resolver Client - Fetch and parse data from external URLs +// ============================================================================ + +const DEFAULT_URL_TIMEOUT_SECS: u64 = 30; + +pub struct UrlResolverClient { + client: reqwest::Client, +} + +impl Default for UrlResolverClient { + fn default() -> Self { + Self::new() + } +} + +impl UrlResolverClient { + pub fn new() -> Self { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS)) + .build() + .expect("Failed to create HTTP client for URL resolver"); + + Self { client } + } + + pub fn with_timeout(timeout_secs: u64) -> Self { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build() + .expect("Failed to create HTTP client for URL resolver"); + + Self { client } + } + + /// Resolve a URL and return the parsed JSON response + pub async fn resolve( + &self, + url: &str, + method: &crate::ast::HttpMethod, + ) -> Result> { + if url.is_empty() { + return Err("URL is empty".into()); + } + + let response = match method { + crate::ast::HttpMethod::Get => self.client.get(url).send().await?, + crate::ast::HttpMethod::Post => self.client.post(url).send().await?, + }; + + let response = response.error_for_status()?; + let value = response.json::().await?; + + Ok(value) + } + + /// Resolve a URL and extract a specific JSON path from the response + pub async fn resolve_with_extract( + &self, + url: &str, + method: &crate::ast::HttpMethod, + extract_path: Option<&str>, + ) -> Result> { + let response = self.resolve(url, method).await?; + + if let Some(path) = extract_path { + Self::extract_json_path(&response, path) + } else { + Ok(response) + } + } + + /// Extract a value from a JSON object using dot-notation path + /// e.g., "data.image" extracts response["data"]["image"] + pub fn extract_json_path( + value: &Value, + path: &str, + ) -> Result> { + if path.is_empty() { + return Ok(value.clone()); + } + + let mut current = value; + for segment in path.split('.') { + // Try as object key first + if let Some(next) = current.get(segment) { + current = next; + } else if let Ok(index) = segment.parse::() { + // Try as array index + if let Some(next) = current.get(index) { + current = next; + } else { + return Ok(Value::Null); + } + } else { + return Ok(Value::Null); + } + } + + Ok(current.clone()) + } + + /// Batch resolve multiple URLs (each URL resolved independently) + pub async fn resolve_batch( + &self, + urls: &[(String, crate::ast::HttpMethod, Option)], + ) -> HashMap { + let mut results = HashMap::new(); + + for (url, method, extract_path) in urls { + if url.is_empty() { + continue; + } + + match self + .resolve_with_extract(url, method, extract_path.as_deref()) + .await + { + Ok(value) => { + results.insert(url.clone(), value); + } + Err(e) => { + tracing::warn!(url = %url, error = %e, "Failed to resolve URL"); + } + } + } + + results + } +} + struct TokenMetadataResolver; const TOKEN_METADATA_METHODS: &[ResolverComputedMethod] = &[ diff --git a/interpreter/src/vm.rs b/interpreter/src/vm.rs index 45c2085..0b35226 100644 --- a/interpreter/src/vm.rs +++ b/interpreter/src/vm.rs @@ -376,9 +376,10 @@ fn value_to_cache_key(value: &Value) -> String { } } -fn resolver_type_key(resolver: &ResolverType) -> &'static str { +fn resolver_type_key(resolver: &ResolverType) -> String { match resolver { - ResolverType::Token => "token", + ResolverType::Token => "token".to_string(), + ResolverType::Url(config) => format!("url:{}", config.url_path), } } From 8440b03c061f66bd56998eb21525ddf644f42bf8 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:29:09 +0000 Subject: [PATCH 02/13] feat: add new resolve_url macro --- .gitignore | 1 + hyperstack-macros/src/lib.rs | 4 +- hyperstack-macros/src/parse/attributes.rs | 45 +-- .../src/stream_spec/ast_writer.rs | 9 +- .../.hyperstack/PumpfunStream.stack.json | 43 ++- stacks/pumpfun/Cargo.lock | 258 +++++++----------- stacks/pumpfun/src/stack.rs | 4 + 7 files changed, 163 insertions(+), 201 deletions(-) diff --git a/.gitignore b/.gitignore index 0e6ae28..7efd2f1 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,4 @@ docs/.astro # Examples - ignore lock files since they use local file: links examples/*/package-lock.json +examples/pumpfun-server/ diff --git a/hyperstack-macros/src/lib.rs b/hyperstack-macros/src/lib.rs index 34cf2ad..f7f3929 100644 --- a/hyperstack-macros/src/lib.rs +++ b/hyperstack-macros/src/lib.rs @@ -103,6 +103,7 @@ pub fn hyperstack(attr: TokenStream, item: TokenStream) -> TokenStream { /// - `#[aggregate(...)]` - Aggregate field values /// - `#[computed(...)]` - Computed fields from other fields /// - `#[derive_from(...)]` - Derive values from instructions +/// - `#[url_resolve(...)]` - Fetch and extract data from external URLs #[proc_macro_derive( Stream, attributes( @@ -113,7 +114,8 @@ pub fn hyperstack(attr: TokenStream, item: TokenStream) -> TokenStream { aggregate, computed, derive_from, - resolve + resolve, + url_resolve ) )] pub fn stream_derive(_input: TokenStream) -> TokenStream { diff --git a/hyperstack-macros/src/parse/attributes.rs b/hyperstack-macros/src/parse/attributes.rs index 5918815..64a9980 100644 --- a/hyperstack-macros/src/parse/attributes.rs +++ b/hyperstack-macros/src/parse/attributes.rs @@ -1169,7 +1169,6 @@ struct UrlResolveAttributeArgs { url: Option, method: Option, extract: Option, - strategy: Option, } impl Parse for UrlResolveAttributeArgs { @@ -1177,7 +1176,6 @@ impl Parse for UrlResolveAttributeArgs { let mut url = None; let mut method = None; let mut extract = None; - let mut strategy = None; while !input.is_empty() { let ident: syn::Ident = input.parse()?; @@ -1186,22 +1184,24 @@ impl Parse for UrlResolveAttributeArgs { input.parse::()?; if ident_str == "url" { - // Parse as path (e.g., info.uri) and convert to string - let path: Path = input.parse()?; - url = Some( - path.segments - .iter() - .map(|seg| seg.ident.to_string()) - .collect::>() - .join("."), - ); + // Parse as dotted path (e.g., info.uri) - handle both dot-separated and single identifiers + let mut parts = Vec::new(); + let first: syn::Ident = input.parse()?; + parts.push(first.to_string()); + + // Parse any additional .identifier segments + while input.peek(Token![.]) { + input.parse::()?; + let next: syn::Ident = input.parse()?; + parts.push(next.to_string()); + } + + url = Some(parts.join(".")); } else if ident_str == "method" { method = Some(input.parse()?); } else if ident_str == "extract" { let lit: syn::LitStr = input.parse()?; extract = Some(lit.value()); - } else if ident_str == "strategy" { - strategy = Some(input.parse()?); } else { return Err(syn::Error::new( ident.span(), @@ -1218,7 +1218,6 @@ impl Parse for UrlResolveAttributeArgs { url, method, extract, - strategy, }) } } @@ -1253,27 +1252,11 @@ pub fn parse_url_resolve_attribute( HttpMethod::Get }; - let strategy = args - .strategy - .map(|s| s.to_string()) - .unwrap_or_else(|| "SetOnce".to_string()); - - // Validate strategy - if strategy != "SetOnce" && strategy != "LastWrite" { - return Err(syn::Error::new_spanned( - attr, - format!( - "Invalid strategy '{}' for #[url_resolve]. Only 'SetOnce' or 'LastWrite' are allowed.", - strategy - ), - )); - } - Ok(Some(UrlResolveAttribute { url_path, method, extract_path: args.extract, - strategy, + strategy: "SetOnce".to_string(), target_field_name: target_field_name.to_string(), })) } diff --git a/hyperstack-macros/src/stream_spec/ast_writer.rs b/hyperstack-macros/src/stream_spec/ast_writer.rs index 18b9832..94f547d 100644 --- a/hyperstack-macros/src/stream_spec/ast_writer.rs +++ b/hyperstack-macros/src/stream_spec/ast_writer.rs @@ -208,9 +208,16 @@ fn build_resolver_specs(resolve_specs: &[parse::ResolveSpec]) -> Vec None, + _ => spec.extract.clone(), + }; + let extract = ResolverExtractSpec { target_path: spec.target_field_name.clone(), - source_path: spec.extract.clone(), + source_path, transform: None, }; diff --git a/stacks/pumpfun/.hyperstack/PumpfunStream.stack.json b/stacks/pumpfun/.hyperstack/PumpfunStream.stack.json index 1570dc7..aa54f77 100644 --- a/stacks/pumpfun/.hyperstack/PumpfunStream.stack.json +++ b/stacks/pumpfun/.hyperstack/PumpfunStream.stack.json @@ -5348,6 +5348,16 @@ "inner_type": "bool", "source_path": null, "resolved_type": null + }, + { + "field_name": "resolved_image", + "rust_type_name": "Option < String >", + "base_type": "String", + "is_optional": true, + "is_array": false, + "inner_type": "String", + "source_path": null, + "resolved_type": null } ], "is_nested_struct": false, @@ -6689,6 +6699,16 @@ "source_path": null, "resolved_type": null }, + "info.resolved_image": { + "field_name": "resolved_image", + "rust_type_name": "Option < String >", + "base_type": "String", + "is_optional": true, + "is_array": false, + "inner_type": "String", + "source_path": null, + "resolved_type": null + }, "info.symbol": { "field_name": "symbol", "rust_type_name": "Option < String >", @@ -7262,7 +7282,24 @@ } } ], - "resolver_specs": [], + "resolver_specs": [ + { + "resolver": { + "url": { + "url_path": "info.uri", + "method": "get", + "extract_path": "image" + } + }, + "input_path": "info.uri", + "strategy": "SetOnce", + "extracts": [ + { + "target_path": "info.resolved_image" + } + ] + } + ], "computed_fields": [ "trading.last_trade_price", "trading.total_volume", @@ -7409,7 +7446,7 @@ "result_type": "Option < f64 >" } ], - "content_hash": "bb7da3279a1983c38e3e2d84c91babe722c3e4d9e32963b5d26cba8be745e7bd", + "content_hash": "1bc6f8116859f576b2730b2de2bf9fab4090643faa9bf7bf814d31642ff7fc38", "views": [] } ], @@ -17939,5 +17976,5 @@ "program_id": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P" } ], - "content_hash": "9edc87a2beb6a7fc98dc3a46d6fe0cbd9acd61f71acaf36818669bcd7bd7a8a1" + "content_hash": "f7a952f5de70efe998814a8d532f022a21724f1ca47d5dcc1d72ffdcc99a6578" } \ No newline at end of file diff --git a/stacks/pumpfun/Cargo.lock b/stacks/pumpfun/Cargo.lock index 53d93d1..3f7755f 100644 --- a/stacks/pumpfun/Cargo.lock +++ b/stacks/pumpfun/Cargo.lock @@ -725,21 +725,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -868,9 +853,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1103,6 +1090,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.5", ] [[package]] @@ -1118,22 +1106,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.8.1", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.19" @@ -1153,16 +1125,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.1", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] name = "hyperstack" -version = "0.4.3" +version = "0.5.2" dependencies = [ "anyhow", "bs58", @@ -1185,7 +1155,7 @@ dependencies = [ [[package]] name = "hyperstack-interpreter" -version = "0.4.3" +version = "0.5.2" dependencies = [ "bs58", "dashmap", @@ -1206,7 +1176,7 @@ dependencies = [ [[package]] name = "hyperstack-macros" -version = "0.4.3" +version = "0.5.2" dependencies = [ "bs58", "hex", @@ -1220,7 +1190,7 @@ dependencies = [ [[package]] name = "hyperstack-sdk" -version = "0.4.3" +version = "0.5.2" dependencies = [ "anyhow", "flate2", @@ -1237,7 +1207,7 @@ dependencies = [ [[package]] name = "hyperstack-server" -version = "0.4.3" +version = "0.5.2" dependencies = [ "anyhow", "base64 0.22.1", @@ -1503,6 +1473,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1563,23 +1539,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.1.6", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1620,56 +1579,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking_lot" version = "0.11.2" @@ -1992,6 +1907,61 @@ dependencies = [ "solana-pubkey", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.36", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.43" @@ -2162,29 +2132,26 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", - "h2 0.4.13", "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", "hyper-rustls 0.27.7", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.36", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tokio-native-tls", + "tokio-rustls 0.26.4", "tower 0.5.3", "tower-http", "tower-service", @@ -2192,6 +2159,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.5", ] [[package]] @@ -2208,6 +2176,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.1.3" @@ -2255,6 +2229,7 @@ checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.9", "subtle", @@ -2267,10 +2242,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.0", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework", ] [[package]] @@ -2288,6 +2263,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2360,19 +2336,6 @@ dependencies = [ "untrusted", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.5.1" @@ -2825,16 +2788,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.24.1" @@ -3378,12 +3331,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -3483,6 +3430,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.4" @@ -3535,35 +3492,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.48.0" diff --git a/stacks/pumpfun/src/stack.rs b/stacks/pumpfun/src/stack.rs index 7d59c63..8a7c316 100644 --- a/stacks/pumpfun/src/stack.rs +++ b/stacks/pumpfun/src/stack.rs @@ -59,6 +59,10 @@ pub mod pumpfun_stream { #[map(pump_sdk::accounts::BondingCurve::complete, strategy = LastWrite)] pub is_complete: Option, + + // URL resolver test: fetch and extract image from metadata URI + #[url_resolve(url = info.uri, extract = "image")] + pub resolved_image: Option, } // ReserveState section: All fields come from BondingCurve account updates From 25071126c948763e7e2771443b5bb4997f2519af Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:48:45 +0000 Subject: [PATCH 03/13] refactor: Uniform resolve macro - Updated the `hyperstack` macro documentation to reflect the new `#[resolve(...)]` attribute. - Refactored parsing logic to support new parameters for URL resolution, including `url` and `method`. - Removed the deprecated `url_resolve` attribute and integrated its functionality into the `resolve` attribute. - Enhanced error handling for mutually exclusive parameters in the `resolve` attribute. - Updated entity processing to accommodate the new resolver logic for URL and token sources. --- hyperstack-macros/src/lib.rs | 5 +- hyperstack-macros/src/parse/attributes.rs | 167 +++++------------- hyperstack-macros/src/stream_spec/entity.rs | 50 +++--- hyperstack-macros/src/stream_spec/sections.rs | 69 ++++---- stacks/pumpfun/src/stack.rs | 4 +- 5 files changed, 114 insertions(+), 181 deletions(-) diff --git a/hyperstack-macros/src/lib.rs b/hyperstack-macros/src/lib.rs index f7f3929..8e2fab9 100644 --- a/hyperstack-macros/src/lib.rs +++ b/hyperstack-macros/src/lib.rs @@ -103,7 +103,7 @@ pub fn hyperstack(attr: TokenStream, item: TokenStream) -> TokenStream { /// - `#[aggregate(...)]` - Aggregate field values /// - `#[computed(...)]` - Computed fields from other fields /// - `#[derive_from(...)]` - Derive values from instructions -/// - `#[url_resolve(...)]` - Fetch and extract data from external URLs +/// - `#[resolve(...)]` - Resolve external data (token metadata via DAS API or data from URLs) #[proc_macro_derive( Stream, attributes( @@ -114,8 +114,7 @@ pub fn hyperstack(attr: TokenStream, item: TokenStream) -> TokenStream { aggregate, computed, derive_from, - resolve, - url_resolve + resolve ) )] pub fn stream_derive(_input: TokenStream) -> TokenStream { diff --git a/hyperstack-macros/src/parse/attributes.rs b/hyperstack-macros/src/parse/attributes.rs index 64a9980..873fb41 100644 --- a/hyperstack-macros/src/parse/attributes.rs +++ b/hyperstack-macros/src/parse/attributes.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use syn::parse::{Parse, ParseStream}; use syn::{Attribute, Path, Token}; -use crate::ast::{HttpMethod, ResolverType}; +use crate::ast::ResolverType; #[derive(Debug, Clone)] pub struct RegisterFromSpec { @@ -1004,6 +1004,8 @@ pub fn parse_aggregate_attribute( pub struct ResolveAttribute { pub from: Option, pub address: Option, + pub url: Option, + pub method: Option, pub extract: Option, pub target_field_name: String, pub resolver: Option, @@ -1023,6 +1025,8 @@ pub struct ResolveSpec { struct ResolveAttributeArgs { from: Option, address: Option, + url: Option, + method: Option, extract: Option, resolver: Option, strategy: Option, @@ -1032,6 +1036,8 @@ impl Parse for ResolveAttributeArgs { fn parse(input: ParseStream) -> syn::Result { let mut from = None; let mut address = None; + let mut url = None; + let mut method = None; let mut extract = None; let mut resolver = None; let mut strategy = None; @@ -1048,6 +1054,22 @@ impl Parse for ResolveAttributeArgs { } else if ident_str == "address" { let lit: syn::LitStr = input.parse()?; address = Some(lit.value()); + } else if ident_str == "url" { + // Parse as dotted path (e.g., info.uri) - handle both dot-separated and single identifiers + let mut parts = Vec::new(); + let first: syn::Ident = input.parse()?; + parts.push(first.to_string()); + + // Parse any additional .identifier segments + while input.peek(Token![.]) { + input.parse::()?; + let next: syn::Ident = input.parse()?; + parts.push(next.to_string()); + } + + url = Some(parts.join(".")); + } else if ident_str == "method" { + method = Some(input.parse()?); } else if ident_str == "extract" { let lit: syn::LitStr = input.parse()?; extract = Some(lit.value()); @@ -1077,6 +1099,8 @@ impl Parse for ResolveAttributeArgs { Ok(ResolveAttributeArgs { from, address, + url, + method, extract, resolver, strategy, @@ -1094,6 +1118,25 @@ pub fn parse_resolve_attribute( let args: ResolveAttributeArgs = attr.parse_args()?; + // Check for mutually exclusive parameters: url vs (from/address) + let has_url = args.url.is_some(); + let has_token_source = args.from.is_some() || args.address.is_some(); + + if has_url && has_token_source { + return Err(syn::Error::new_spanned( + attr, + "#[resolve] cannot specify 'url' together with 'from' or 'address'", + )); + } + + if !has_url && !has_token_source { + return Err(syn::Error::new_spanned( + attr, + "#[resolve] requires either 'url' or 'from'/'address' parameter", + )); + } + + // Token resolvers: cannot have both from and address if args.from.is_some() && args.address.is_some() { return Err(syn::Error::new_spanned( attr, @@ -1101,10 +1144,11 @@ pub fn parse_resolve_attribute( )); } - if args.from.is_none() && args.address.is_none() { + // URL resolvers require extract parameter + if has_url && args.extract.is_none() { return Err(syn::Error::new_spanned( attr, - "#[resolve] requires either 'from' or 'address' parameter", + "#[resolve] with 'url' requires 'extract' parameter", )); } @@ -1113,6 +1157,8 @@ pub fn parse_resolve_attribute( Ok(Some(ResolveAttribute { from: args.from, address: args.address, + url: args.url, + method: args.method.map(|m| m.to_string()), extract: args.extract, target_field_name: target_field_name.to_string(), resolver: args.resolver, @@ -1146,121 +1192,6 @@ pub fn parse_computed_attribute( target_field_name: target_field_name.to_string(), })) } - -// ============================================================================ -// URL Resolve Macro - Fetch and parse data from external URLs -// ============================================================================ - -#[derive(Debug, Clone)] -pub struct UrlResolveAttribute { - /// Field path to get the URL from (e.g., "info.uri") - pub url_path: String, - /// HTTP method to use (default: GET) - pub method: HttpMethod, - /// JSON path to extract from response (None = full payload) - pub extract_path: Option, - /// Strategy for updates (SetOnce or LastWrite) - pub strategy: String, - /// Target field name - pub target_field_name: String, -} - -struct UrlResolveAttributeArgs { - url: Option, - method: Option, - extract: Option, -} - -impl Parse for UrlResolveAttributeArgs { - fn parse(input: ParseStream) -> syn::Result { - let mut url = None; - let mut method = None; - let mut extract = None; - - while !input.is_empty() { - let ident: syn::Ident = input.parse()?; - let ident_str = ident.to_string(); - - input.parse::()?; - - if ident_str == "url" { - // Parse as dotted path (e.g., info.uri) - handle both dot-separated and single identifiers - let mut parts = Vec::new(); - let first: syn::Ident = input.parse()?; - parts.push(first.to_string()); - - // Parse any additional .identifier segments - while input.peek(Token![.]) { - input.parse::()?; - let next: syn::Ident = input.parse()?; - parts.push(next.to_string()); - } - - url = Some(parts.join(".")); - } else if ident_str == "method" { - method = Some(input.parse()?); - } else if ident_str == "extract" { - let lit: syn::LitStr = input.parse()?; - extract = Some(lit.value()); - } else { - return Err(syn::Error::new( - ident.span(), - format!("Unknown url_resolve attribute argument: {}", ident_str), - )); - } - - if !input.is_empty() { - input.parse::()?; - } - } - - Ok(UrlResolveAttributeArgs { - url, - method, - extract, - }) - } -} - -/// Parse #[url_resolve(url = info.uri, method = GET, extract = "image", strategy = SetOnce)] attribute -pub fn parse_url_resolve_attribute( - attr: &Attribute, - target_field_name: &str, -) -> syn::Result> { - if !attr.path().is_ident("url_resolve") { - return Ok(None); - } - - let args: UrlResolveAttributeArgs = attr.parse_args()?; - - let url_path = args.url.ok_or_else(|| { - syn::Error::new_spanned(attr, "#[url_resolve] requires 'url' parameter specifying the field path to get the URL from") - })?; - - let method = if let Some(method_ident) = args.method { - match method_ident.to_string().to_lowercase().as_str() { - "get" => HttpMethod::Get, - "post" => HttpMethod::Post, - _ => { - return Err(syn::Error::new_spanned( - method_ident, - "Invalid HTTP method. Only 'GET' or 'POST' are supported.", - )); - } - } - } else { - HttpMethod::Get - }; - - Ok(Some(UrlResolveAttribute { - url_path, - method, - extract_path: args.extract, - strategy: "SetOnce".to_string(), - target_field_name: target_field_name.to_string(), - })) -} - pub fn has_entity_attribute(attrs: &[Attribute]) -> bool { attrs.iter().any(|attr| attr.path().is_ident("entity")) } diff --git a/hyperstack-macros/src/stream_spec/entity.rs b/hyperstack-macros/src/stream_spec/entity.rs index 1b0a62c..efa608b 100644 --- a/hyperstack-macros/src/stream_spec/entity.rs +++ b/hyperstack-macros/src/stream_spec/entity.rs @@ -18,7 +18,7 @@ use std::collections::{HashMap, HashSet}; use quote::{format_ident, quote}; use syn::{Fields, GenericArgument, ItemStruct, PathArguments, Type}; -use crate::ast::{EntitySection, FieldTypeInfo, ResolverHook, ResolverType, UrlResolverConfig}; +use crate::ast::{EntitySection, FieldTypeInfo, HttpMethod, ResolverHook, ResolverType, UrlResolverConfig}; use crate::codegen; use crate::event_type_helpers::IdlLookup; use crate::parse; @@ -424,16 +424,34 @@ pub fn process_entity_struct_with_idl( pub #field_name: #field_type }); - let resolver = if let Some(name) = resolve_attr.resolver.as_deref() { + // Determine resolver type: URL resolver if url is present, otherwise Token resolver + let resolver = if let Some(url_path) = resolve_attr.url.clone() { + // URL resolver + let method = resolve_attr.method.as_deref().map(|m| { + match m.to_lowercase().as_str() { + "post" => HttpMethod::Post, + _ => HttpMethod::Get, + } + }).unwrap_or(HttpMethod::Get); + + ResolverType::Url(UrlResolverConfig { + url_path, + method, + extract_path: resolve_attr.extract.clone(), + }) + } else if let Some(name) = resolve_attr.resolver.as_deref() { + // Token resolver with explicit type parse_resolver_type_name(name, field_type) + .unwrap_or_else(|err| panic!("{}", err)) } else { + // Token resolver with inferred type infer_resolver_type(field_type) - } - .unwrap_or_else(|err| panic!("{}", err)); + .unwrap_or_else(|err| panic!("{}", err)) + }; resolve_specs.push(parse::ResolveSpec { resolver, - from: resolve_attr.from, + from: resolve_attr.url.clone().or(resolve_attr.from), address: resolve_attr.address, extract: resolve_attr.extract, target_field_name: resolve_attr.target_field_name, @@ -454,28 +472,6 @@ pub fn process_entity_struct_with_idl( computed_attr.expression.clone(), field_type.clone(), )); - } else if let Ok(Some(url_resolve_attr)) = - parse::parse_url_resolve_attribute(attr, &field_name.to_string()) - { - has_attrs = true; - - state_fields.push(quote! { - pub #field_name: #field_type - }); - - // Create URL resolver spec - resolve_specs.push(parse::ResolveSpec { - resolver: ResolverType::Url(UrlResolverConfig { - url_path: url_resolve_attr.url_path.clone(), - method: url_resolve_attr.method.clone(), - extract_path: url_resolve_attr.extract_path.clone(), - }), - from: Some(url_resolve_attr.url_path), - address: None, - extract: url_resolve_attr.extract_path, - target_field_name: url_resolve_attr.target_field_name, - strategy: url_resolve_attr.strategy, - }); } } diff --git a/hyperstack-macros/src/stream_spec/sections.rs b/hyperstack-macros/src/stream_spec/sections.rs index fb5ee50..70387e9 100644 --- a/hyperstack-macros/src/stream_spec/sections.rs +++ b/hyperstack-macros/src/stream_spec/sections.rs @@ -633,53 +633,60 @@ pub fn process_nested_struct( } else if let Ok(Some(resolve_attr)) = parse::parse_resolve_attribute(attr, &field_name.to_string()) { - let resolver = if let Some(name) = resolve_attr.resolver.as_deref() { + // Determine resolver type: URL resolver if url is present, otherwise Token resolver + let resolver = if let Some(url_path_raw) = resolve_attr.url.clone() { + // Qualify the url_path with section name if needed + let url_path = if url_path_raw.contains('.') { + url_path_raw + } else { + format!("{}.{}", section_name, url_path_raw) + }; + + let method = resolve_attr.method.as_deref().map(|m| { + match m.to_lowercase().as_str() { + "post" => crate::ast::HttpMethod::Post, + _ => crate::ast::HttpMethod::Get, + } + }).unwrap_or(crate::ast::HttpMethod::Get); + + crate::ast::ResolverType::Url(crate::ast::UrlResolverConfig { + url_path, + method, + extract_path: resolve_attr.extract.clone(), + }) + } else if let Some(name) = resolve_attr.resolver.as_deref() { super::entity::parse_resolver_type_name(name, field_type) + .unwrap_or_else(|err| panic!("{}", err)) } else { super::entity::infer_resolver_type(field_type) - } - .unwrap_or_else(|err| panic!("{}", err)); + .unwrap_or_else(|err| panic!("{}", err)) + }; let mut target_field_name = resolve_attr.target_field_name; if !target_field_name.contains('.') { target_field_name = format!("{}.{}", section_name, target_field_name); } + // For URL resolvers, qualify the url with section name if needed + let from = if let Some(url) = resolve_attr.url.clone() { + let url_path = if url.contains('.') { + url + } else { + format!("{}.{}", section_name, url) + }; + Some(url_path) + } else { + resolve_attr.from + }; + resolve_specs.push(parse::ResolveSpec { resolver, - from: resolve_attr.from, + from, address: resolve_attr.address, extract: resolve_attr.extract, target_field_name, strategy: resolve_attr.strategy, }); - } else if let Ok(Some(url_resolve_attr)) = - parse::parse_url_resolve_attribute(attr, &field_name.to_string()) - { - let mut target_field_name = url_resolve_attr.target_field_name.clone(); - if !target_field_name.contains('.') { - target_field_name = format!("{}.{}", section_name, target_field_name); - } - - // Qualify the url_path with section name if needed - let url_path = if url_resolve_attr.url_path.contains('.') { - url_resolve_attr.url_path.clone() - } else { - format!("{}.{}", section_name, url_resolve_attr.url_path) - }; - - resolve_specs.push(parse::ResolveSpec { - resolver: crate::ast::ResolverType::Url(crate::ast::UrlResolverConfig { - url_path: url_path.clone(), - method: url_resolve_attr.method.clone(), - extract_path: url_resolve_attr.extract_path.clone(), - }), - from: Some(url_path), - address: None, - extract: url_resolve_attr.extract_path, - target_field_name, - strategy: url_resolve_attr.strategy, - }); } } } diff --git a/stacks/pumpfun/src/stack.rs b/stacks/pumpfun/src/stack.rs index 8a7c316..b7490a0 100644 --- a/stacks/pumpfun/src/stack.rs +++ b/stacks/pumpfun/src/stack.rs @@ -60,8 +60,8 @@ pub mod pumpfun_stream { #[map(pump_sdk::accounts::BondingCurve::complete, strategy = LastWrite)] pub is_complete: Option, - // URL resolver test: fetch and extract image from metadata URI - #[url_resolve(url = info.uri, extract = "image")] + // URL resolver: fetch and extract image from metadata URI + #[resolve(url = info.uri, extract = "image")] pub resolved_image: Option, } From 296bc5b01a467265b265912c0d5dc3583e9a9cc0 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:18:28 +0000 Subject: [PATCH 04/13] chore: Bump hs cli version on ORE cargo.lock --- stacks/ore/Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stacks/ore/Cargo.lock b/stacks/ore/Cargo.lock index 6a80fc0..60730a9 100644 --- a/stacks/ore/Cargo.lock +++ b/stacks/ore/Cargo.lock @@ -1138,7 +1138,7 @@ dependencies = [ [[package]] name = "hyperstack" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "bs58", @@ -1161,7 +1161,7 @@ dependencies = [ [[package]] name = "hyperstack-interpreter" -version = "0.5.1" +version = "0.5.2" dependencies = [ "bs58", "dashmap", @@ -1182,7 +1182,7 @@ dependencies = [ [[package]] name = "hyperstack-macros" -version = "0.5.1" +version = "0.5.2" dependencies = [ "bs58", "hex", @@ -1196,7 +1196,7 @@ dependencies = [ [[package]] name = "hyperstack-sdk" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "flate2", @@ -1213,7 +1213,7 @@ dependencies = [ [[package]] name = "hyperstack-server" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "base64 0.22.1", From 85ea67d009ee8b7c3b9872c1282e962e936dac37 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:20:36 +0000 Subject: [PATCH 05/13] chore: Regenerate TS SDK --- stacks/sdk/typescript/src/pumpfun/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stacks/sdk/typescript/src/pumpfun/index.ts b/stacks/sdk/typescript/src/pumpfun/index.ts index 8f94044..695d17a 100644 --- a/stacks/sdk/typescript/src/pumpfun/index.ts +++ b/stacks/sdk/typescript/src/pumpfun/index.ts @@ -17,6 +17,7 @@ export interface PumpfunTokenId { export interface PumpfunTokenInfo { is_complete?: boolean | null; name?: string | null; + resolved_image?: string | null; symbol?: string | null; uri?: string | null; } @@ -278,6 +279,7 @@ export const PumpfunTokenIdSchema = z.object({ export const PumpfunTokenInfoSchema = z.object({ is_complete: z.boolean().nullable().optional(), name: z.string().nullable().optional(), + resolved_image: z.string().nullable().optional(), symbol: z.string().nullable().optional(), uri: z.string().nullable().optional(), }); From dc204a2cfae505b7cf603b4eb56b39e0d8277f97 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:58:07 +0000 Subject: [PATCH 06/13] fix: re-queue URL resolver requests on empty URL or failure --- docs/unified-resolve-macro.md | 73 +++++++++++++++++++ .../src/codegen/vixen_runtime.rs | 12 ++- 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 docs/unified-resolve-macro.md diff --git a/docs/unified-resolve-macro.md b/docs/unified-resolve-macro.md new file mode 100644 index 0000000..908e911 --- /dev/null +++ b/docs/unified-resolve-macro.md @@ -0,0 +1,73 @@ +# Extended Resolve Macro - URL Resolution Support + +## Overview + +This PR extends the `#[resolve]` macro to support URL-based data fetching in addition to the existing Token metadata resolution via DAS API. + +## New Capability + +The `#[resolve]` macro now supports fetching and extracting data from HTTP URLs: + +```rust +// Token resolver (existing functionality) +#[resolve(address = "oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp")] +pub ore_metadata: Option, + +// URL resolver (new) +#[resolve(url = info.uri, extract = "image")] +pub resolved_image: Option, + +// URL resolver with HTTP method +#[resolve(url = api.endpoint, method = POST, extract = "data.result")] +pub result: Option, +``` + +## Resolver Type Detection + +The macro automatically determines the resolver type based on parameters: + +| Parameters | Resolver Type | +|------------|---------------| +| `url = ...` | URL Resolver (HTTP fetch + JSON extraction) | +| `address = ...` or `from = ...` | Token Resolver (DAS API) | + +Parameters are mutually exclusive - specifying both `url` and `address`/`from` is a compile error. + +## URL Resolver Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `url` | Yes | Field path containing the URL to fetch (e.g., `info.uri`) | +| `extract` | Yes | JSON path to extract from response (e.g., `"image"`, `"data.nested.field"`) | +| `method` | No | HTTP method: `GET` (default) or `POST` | + +## Example Usage + +```rust +pub struct TokenInfo { + #[from_instruction([Create::uri], strategy = SetOnce)] + pub uri: Option, + + // Fetch metadata JSON from uri and extract the "image" field + #[resolve(url = info.uri, extract = "image")] + pub resolved_image: Option, +} +``` + +## Changes + +### Files Modified + +- **`hyperstack-macros/src/parse/attributes.rs`** + - Added `url` and `method` fields to `ResolveAttributeArgs` + - Updated parser to handle dot-path syntax for URL field references + - Added validation for mutually exclusive parameters + +- **`hyperstack-macros/src/stream_spec/entity.rs`** + - Updated resolve branch to create `ResolverType::Url` when `url` parameter is present + +- **`hyperstack-macros/src/stream_spec/sections.rs`** + - Same updates as entity.rs with section-name prefixing for unqualified paths + +- **`stacks/pumpfun/src/stack.rs`** + - Added example usage of URL resolution \ No newline at end of file diff --git a/hyperstack-macros/src/codegen/vixen_runtime.rs b/hyperstack-macros/src/codegen/vixen_runtime.rs index 980bd71..294ff0d 100644 --- a/hyperstack-macros/src/codegen/vixen_runtime.rs +++ b/hyperstack-macros/src/codegen/vixen_runtime.rs @@ -455,6 +455,8 @@ pub fn generate_vm_handler( }; if url.is_empty() { + let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); + vm.restore_resolver_requests(vec![request]); continue; } @@ -481,9 +483,11 @@ pub fn generate_vm_handler( Err(err) => { hyperstack::runtime::tracing::warn!( url = %url, - "URL resolver request failed: {}", + "URL resolver request failed, re-queuing: {}", err ); + let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); + vm.restore_resolver_requests(vec![request]); } } } @@ -1424,6 +1428,8 @@ pub fn generate_vm_handler_struct() -> TokenStream { }; if url.is_empty() { + let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); + vm.restore_resolver_requests(vec![request]); continue; } @@ -1450,9 +1456,11 @@ pub fn generate_vm_handler_struct() -> TokenStream { Err(err) => { hyperstack::runtime::tracing::warn!( url = %url, - "URL resolver request failed: {}", + "URL resolver request failed, re-queuing: {}", err ); + let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); + vm.restore_resolver_requests(vec![request]); } } } From 540aeefed35be873067f41fc14daa205c4e959d9 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:59:14 +0000 Subject: [PATCH 07/13] fix: improve error handling in UrlResolverClient for out-of-bounds access --- interpreter/src/resolvers.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interpreter/src/resolvers.rs b/interpreter/src/resolvers.rs index 270977c..b3beb04 100644 --- a/interpreter/src/resolvers.rs +++ b/interpreter/src/resolvers.rs @@ -586,10 +586,10 @@ impl UrlResolverClient { if let Some(next) = current.get(index) { current = next; } else { - return Ok(Value::Null); + return Err(format!("Index '{}' out of bounds in path '{}'", index, path).into()); } } else { - return Ok(Value::Null); + return Err(format!("Key '{}' not found in path '{}'", segment, path).into()); } } From f346bfa2131d2cb1ae3ea98e03b25e9351f25eca Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:05:54 +0000 Subject: [PATCH 08/13] fix: - Instantiate UrlResolverClient once at startup on VmHandler instead of per-request --- hyperstack-macros/src/codegen/vixen_runtime.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/hyperstack-macros/src/codegen/vixen_runtime.rs b/hyperstack-macros/src/codegen/vixen_runtime.rs index 294ff0d..23f20a7 100644 --- a/hyperstack-macros/src/codegen/vixen_runtime.rs +++ b/hyperstack-macros/src/codegen/vixen_runtime.rs @@ -259,6 +259,7 @@ pub fn generate_vm_handler( health_monitor: Option, slot_tracker: hyperstack::runtime::hyperstack_server::SlotTracker, resolver_client: Option>, + url_resolver_client: std::sync::Arc, } impl std::fmt::Debug for VmHandler { @@ -278,6 +279,7 @@ pub fn generate_vm_handler( health_monitor: Option, slot_tracker: hyperstack::runtime::hyperstack_server::SlotTracker, resolver_client: Option>, + url_resolver_client: std::sync::Arc, ) -> Self { Self { vm, @@ -286,6 +288,7 @@ pub fn generate_vm_handler( health_monitor, slot_tracker, resolver_client, + url_resolver_client, } } @@ -436,7 +439,7 @@ pub fn generate_vm_handler( // Process URL resolver requests if !url_requests.is_empty() { - let url_client = hyperstack::runtime::hyperstack_interpreter::resolvers::UrlResolverClient::new(); + let url_client = self.url_resolver_client.clone(); for request in url_requests { if let hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Url(config) = &request.resolver { @@ -923,6 +926,8 @@ pub fn generate_spec_function( } }; + let url_resolver_client = Arc::new(hyperstack::runtime::hyperstack_interpreter::resolvers::UrlResolverClient::new()); + let slot_tracker = hyperstack::runtime::hyperstack_server::SlotTracker::new(); let mut attempt = 0u32; let mut backoff = reconnection_config.initial_delay; @@ -964,6 +969,7 @@ pub fn generate_spec_function( health_monitor.clone(), slot_tracker.clone(), resolver_client.clone(), + url_resolver_client.clone(), ); let account_parser = parsers::AccountParser; @@ -1232,6 +1238,7 @@ pub fn generate_vm_handler_struct() -> TokenStream { health_monitor: Option, slot_tracker: hyperstack::runtime::hyperstack_server::SlotTracker, resolver_client: Option>, + url_resolver_client: std::sync::Arc, } impl std::fmt::Debug for VmHandler { @@ -1251,6 +1258,7 @@ pub fn generate_vm_handler_struct() -> TokenStream { health_monitor: Option, slot_tracker: hyperstack::runtime::hyperstack_server::SlotTracker, resolver_client: Option>, + url_resolver_client: std::sync::Arc, ) -> Self { Self { vm, @@ -1259,6 +1267,7 @@ pub fn generate_vm_handler_struct() -> TokenStream { health_monitor, slot_tracker, resolver_client, + url_resolver_client, } } @@ -1409,7 +1418,7 @@ pub fn generate_vm_handler_struct() -> TokenStream { // Process URL resolver requests if !url_requests.is_empty() { - let url_client = hyperstack::runtime::hyperstack_interpreter::resolvers::UrlResolverClient::new(); + let url_client = self.url_resolver_client.clone(); for request in url_requests { if let hyperstack::runtime::hyperstack_interpreter::ast::ResolverType::Url(config) = &request.resolver { @@ -1964,6 +1973,8 @@ pub fn generate_multi_pipeline_spec_function( } }; + let url_resolver_client = Arc::new(hyperstack::runtime::hyperstack_interpreter::resolvers::UrlResolverClient::new()); + let slot_tracker = hyperstack::runtime::hyperstack_server::SlotTracker::new(); let mut attempt = 0u32; let mut backoff = reconnection_config.initial_delay; @@ -2005,6 +2016,7 @@ pub fn generate_multi_pipeline_spec_function( health_monitor.clone(), slot_tracker.clone(), resolver_client.clone(), + url_resolver_client.clone(), ); if attempt == 0 { From fb80a48ebf603a13d72d67cd6aedb602d5818cc3 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:07:20 +0000 Subject: [PATCH 09/13] fix: validate HTTP method input in ResolveAttributeArgs parser to support only 'GET' and 'POST' --- hyperstack-macros/src/parse/attributes.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/hyperstack-macros/src/parse/attributes.rs b/hyperstack-macros/src/parse/attributes.rs index 873fb41..564b86e 100644 --- a/hyperstack-macros/src/parse/attributes.rs +++ b/hyperstack-macros/src/parse/attributes.rs @@ -1069,7 +1069,14 @@ impl Parse for ResolveAttributeArgs { url = Some(parts.join(".")); } else if ident_str == "method" { - method = Some(input.parse()?); + let method_ident: syn::Ident = input.parse()?; + match method_ident.to_string().to_lowercase().as_str() { + "get" | "post" => method = Some(method_ident), + _ => return Err(syn::Error::new( + method_ident.span(), + "Invalid HTTP method. Only 'GET' or 'POST' are supported.", + )), + } } else if ident_str == "extract" { let lit: syn::LitStr = input.parse()?; extract = Some(lit.value()); From 6ed8e948f4490e2aa659c7c25812fdcc968ac64e Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:09:32 +0000 Subject: [PATCH 10/13] fix: eliminate duplicate url_path qualification logic in sections.rs --- hyperstack-macros/src/stream_spec/sections.rs | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/hyperstack-macros/src/stream_spec/sections.rs b/hyperstack-macros/src/stream_spec/sections.rs index 70387e9..5d719a1 100644 --- a/hyperstack-macros/src/stream_spec/sections.rs +++ b/hyperstack-macros/src/stream_spec/sections.rs @@ -634,14 +634,15 @@ pub fn process_nested_struct( parse::parse_resolve_attribute(attr, &field_name.to_string()) { // Determine resolver type: URL resolver if url is present, otherwise Token resolver - let resolver = if let Some(url_path_raw) = resolve_attr.url.clone() { - // Qualify the url_path with section name if needed - let url_path = if url_path_raw.contains('.') { - url_path_raw + let qualified_url = resolve_attr.url.as_deref().map(|url_path_raw| { + if url_path_raw.contains('.') { + url_path_raw.to_string() } else { format!("{}.{}", section_name, url_path_raw) - }; + } + }); + let resolver = if let Some(ref url_path) = qualified_url { let method = resolve_attr.method.as_deref().map(|m| { match m.to_lowercase().as_str() { "post" => crate::ast::HttpMethod::Post, @@ -650,7 +651,7 @@ pub fn process_nested_struct( }).unwrap_or(crate::ast::HttpMethod::Get); crate::ast::ResolverType::Url(crate::ast::UrlResolverConfig { - url_path, + url_path: url_path.clone(), method, extract_path: resolve_attr.extract.clone(), }) @@ -667,17 +668,7 @@ pub fn process_nested_struct( target_field_name = format!("{}.{}", section_name, target_field_name); } - // For URL resolvers, qualify the url with section name if needed - let from = if let Some(url) = resolve_attr.url.clone() { - let url_path = if url.contains('.') { - url - } else { - format!("{}.{}", section_name, url) - }; - Some(url_path) - } else { - resolve_attr.from - }; + let from = qualified_url.or(resolve_attr.from); resolve_specs.push(parse::ResolveSpec { resolver, From e252a8ea2f91b7b7e0dc266586d670e98bae0cfb Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:15:04 +0000 Subject: [PATCH 11/13] perf: parallelize URL batch resolution using join_all --- Cargo.lock | 1 + interpreter/Cargo.toml | 1 + interpreter/src/resolvers.rs | 40 +++++++++++++++++++----------------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ffd04d..a36ed44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1294,6 +1294,7 @@ version = "0.5.3" dependencies = [ "bs58", "dashmap", + "futures", "hex", "hyperstack-macros", "lru", diff --git a/interpreter/Cargo.toml b/interpreter/Cargo.toml index 5de6876..bdd92d4 100644 --- a/interpreter/Cargo.toml +++ b/interpreter/Cargo.toml @@ -26,6 +26,7 @@ lru = "0.12" sha2 = "0.10" tracing = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +futures = "0.3" hyperstack-macros = { version = "0.5.3", path = "../hyperstack-macros" } # OpenTelemetry for distributed tracing and metrics (optional, behind 'otel' feature) diff --git a/interpreter/src/resolvers.rs b/interpreter/src/resolvers.rs index b3beb04..ebe2289 100644 --- a/interpreter/src/resolvers.rs +++ b/interpreter/src/resolvers.rs @@ -1,6 +1,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; +use futures::future::join_all; + use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -596,32 +598,32 @@ impl UrlResolverClient { Ok(current.clone()) } - /// Batch resolve multiple URLs (each URL resolved independently) + /// Batch resolve multiple URLs in parallel pub async fn resolve_batch( &self, urls: &[(String, crate::ast::HttpMethod, Option)], ) -> HashMap { - let mut results = HashMap::new(); - - for (url, method, extract_path) in urls { - if url.is_empty() { - continue; - } - - match self - .resolve_with_extract(url, method, extract_path.as_deref()) - .await - { - Ok(value) => { - results.insert(url.clone(), value); - } + let futures = urls + .iter() + .filter(|(url, _, _)| !url.is_empty()) + .map(|(url, method, extract_path)| async move { + let result = self + .resolve_with_extract(url, method, extract_path.as_deref()) + .await; + (url.clone(), result) + }); + + join_all(futures) + .await + .into_iter() + .filter_map(|(url, result)| match result { + Ok(value) => Some((url, value)), Err(e) => { tracing::warn!(url = %url, error = %e, "Failed to resolve URL"); + None } - } - } - - results + }) + .collect() } } From 367f16f734409bb011d105a2a5ef87ada1f39bb8 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:18:36 +0000 Subject: [PATCH 12/13] refactor: move URL extraction to apply_resolver_result, removing client-level coupling --- hyperstack-macros/src/codegen/vixen_runtime.rs | 4 ++-- hyperstack-macros/src/stream_spec/ast_writer.rs | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/hyperstack-macros/src/codegen/vixen_runtime.rs b/hyperstack-macros/src/codegen/vixen_runtime.rs index 23f20a7..52c5333 100644 --- a/hyperstack-macros/src/codegen/vixen_runtime.rs +++ b/hyperstack-macros/src/codegen/vixen_runtime.rs @@ -463,7 +463,7 @@ pub fn generate_vm_handler( continue; } - match url_client.resolve_with_extract(&url, &config.method, config.extract_path.as_deref()).await { + match url_client.resolve(&url, &config.method).await { Ok(resolved_value) => { let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); match vm.apply_resolver_result( @@ -1442,7 +1442,7 @@ pub fn generate_vm_handler_struct() -> TokenStream { continue; } - match url_client.resolve_with_extract(&url, &config.method, config.extract_path.as_deref()).await { + match url_client.resolve(&url, &config.method).await { Ok(resolved_value) => { let mut vm = self.vm.lock().unwrap_or_else(|e| e.into_inner()); match vm.apply_resolver_result( diff --git a/hyperstack-macros/src/stream_spec/ast_writer.rs b/hyperstack-macros/src/stream_spec/ast_writer.rs index 94f547d..ef6e751 100644 --- a/hyperstack-macros/src/stream_spec/ast_writer.rs +++ b/hyperstack-macros/src/stream_spec/ast_writer.rs @@ -208,12 +208,7 @@ fn build_resolver_specs(resolve_specs: &[parse::ResolveSpec]) -> Vec None, - _ => spec.extract.clone(), - }; + let source_path = spec.extract.clone(); let extract = ResolverExtractSpec { target_path: spec.target_field_name.clone(), From 4ad6d546f33c878913143ce4170c491227652502 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:53:08 +0000 Subject: [PATCH 13/13] chore: Remove unused markdown file --- docs/unified-resolve-macro.md | 73 ----------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 docs/unified-resolve-macro.md diff --git a/docs/unified-resolve-macro.md b/docs/unified-resolve-macro.md deleted file mode 100644 index 908e911..0000000 --- a/docs/unified-resolve-macro.md +++ /dev/null @@ -1,73 +0,0 @@ -# Extended Resolve Macro - URL Resolution Support - -## Overview - -This PR extends the `#[resolve]` macro to support URL-based data fetching in addition to the existing Token metadata resolution via DAS API. - -## New Capability - -The `#[resolve]` macro now supports fetching and extracting data from HTTP URLs: - -```rust -// Token resolver (existing functionality) -#[resolve(address = "oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp")] -pub ore_metadata: Option, - -// URL resolver (new) -#[resolve(url = info.uri, extract = "image")] -pub resolved_image: Option, - -// URL resolver with HTTP method -#[resolve(url = api.endpoint, method = POST, extract = "data.result")] -pub result: Option, -``` - -## Resolver Type Detection - -The macro automatically determines the resolver type based on parameters: - -| Parameters | Resolver Type | -|------------|---------------| -| `url = ...` | URL Resolver (HTTP fetch + JSON extraction) | -| `address = ...` or `from = ...` | Token Resolver (DAS API) | - -Parameters are mutually exclusive - specifying both `url` and `address`/`from` is a compile error. - -## URL Resolver Parameters - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `url` | Yes | Field path containing the URL to fetch (e.g., `info.uri`) | -| `extract` | Yes | JSON path to extract from response (e.g., `"image"`, `"data.nested.field"`) | -| `method` | No | HTTP method: `GET` (default) or `POST` | - -## Example Usage - -```rust -pub struct TokenInfo { - #[from_instruction([Create::uri], strategy = SetOnce)] - pub uri: Option, - - // Fetch metadata JSON from uri and extract the "image" field - #[resolve(url = info.uri, extract = "image")] - pub resolved_image: Option, -} -``` - -## Changes - -### Files Modified - -- **`hyperstack-macros/src/parse/attributes.rs`** - - Added `url` and `method` fields to `ResolveAttributeArgs` - - Updated parser to handle dot-path syntax for URL field references - - Added validation for mutually exclusive parameters - -- **`hyperstack-macros/src/stream_spec/entity.rs`** - - Updated resolve branch to create `ResolverType::Url` when `url` parameter is present - -- **`hyperstack-macros/src/stream_spec/sections.rs`** - - Same updates as entity.rs with section-name prefixing for unqualified paths - -- **`stacks/pumpfun/src/stack.rs`** - - Added example usage of URL resolution \ No newline at end of file