Skip to main content

gestalt/
api.rs

1use std::collections::BTreeMap;
2use std::convert::Infallible;
3use std::future::Future;
4
5use tonic::codegen::async_trait;
6
7use crate::agent_provider::AgentToolRef;
8use crate::catalog::Catalog;
9use crate::error::{Error, Result};
10use crate::proto::v1;
11use crate::rpc_support::GestaltError;
12
13/// Split a canonical subject ID such as `user:ada` into kind and id.
14pub fn parse_subject_id(subject_id: &str) -> Option<(&str, &str)> {
15    let trimmed = subject_id.trim();
16    let (kind, id) = trimmed.split_once(':')?;
17    let kind = kind.trim();
18    let id = id.trim();
19    if kind.is_empty() || id.is_empty() {
20        return None;
21    }
22    Some((kind, id))
23}
24
25#[derive(Clone, Debug, Default, Eq, PartialEq)]
26/// Identifies the caller that initiated an operation.
27pub struct Subject {
28    /// Stable subject id.
29    pub id: String,
30    /// Email address resolved by the Gestalt host for user subjects.
31    pub email: String,
32    /// Human-readable display name resolved by the Gestalt host for user subjects.
33    pub display_name: String,
34}
35
36#[derive(Clone, Debug, Default, Eq, PartialEq)]
37/// Describes the resolved credential used to authorize an operation.
38pub struct Credential {
39    /// Credential mode used by the host.
40    pub mode: String,
41    /// Subject id associated with the credential.
42    pub subject_id: String,
43    /// Connection id or name associated with the credential.
44    pub connection: String,
45    /// Provider instance id or name associated with the credential.
46    pub instance: String,
47}
48
49#[derive(Clone, Debug, Default, Eq, PartialEq)]
50/// Summarizes the host-side access decision attached to an operation.
51pub struct Access {
52    /// Policy name or id applied to the request.
53    pub policy: String,
54    /// Effective role granted to the request.
55    pub role: String,
56}
57
58#[derive(Clone, Debug, Default, Eq, PartialEq)]
59/// Describes public host metadata attached to a request.
60pub struct Host {
61    /// Public base URL for the Gestalt host.
62    pub public_base_url: String,
63}
64
65#[derive(Clone, Debug, Default, PartialEq)]
66/// Carries execution-scoped metadata into typed operation handlers.
67pub struct Request {
68    /// Request token supplied to hosted HTTP operation handlers.
69    pub token: String,
70    /// Connection parameters resolved by the host.
71    pub connection_params: BTreeMap<String, String>,
72    /// Subject that initiated the request.
73    pub subject: Subject,
74    /// Original agent caller when an agent tool runs as a delegated subject.
75    pub agent_subject: Subject,
76    /// Credential used to authorize the request.
77    pub credential: Credential,
78    /// Access decision attached to the request.
79    pub access: Access,
80    /// Public host metadata attached to the request.
81    pub host: Host,
82    /// Idempotency key supplied by the host.
83    pub idempotency_key: String,
84    /// Workflow callback metadata uses a JSON-style lowerCamelCase object
85    /// such as `runId`, `target.steps[0].app.name`,
86    /// `trigger.activationId`, and `trigger.event.specVersion`.
87    pub workflow: serde_json::Map<String, serde_json::Value>,
88    /// Agent tool refs granted to the current operation request.
89    pub tool_refs: Vec<AgentToolRef>,
90    /// Whether the host attached a tool-ref context to this request.
91    pub tool_refs_set: bool,
92}
93
94tokio::task_local! {
95    static REQUEST_CONTEXT: Option<v1::RequestContext>;
96}
97
98impl Request {
99    /// Returns one resolved connection parameter by name.
100    pub fn connection_param(&self, name: &str) -> Option<&str> {
101        self.connection_params.get(name).map(String::as_str)
102    }
103
104    /// Returns a public Gestalt client bound to the current provider request.
105    pub async fn gestalt(
106        &self,
107    ) -> std::result::Result<crate::public::bound::BoundGestaltClient, GestaltError> {
108        crate::public::gestalt_from_context().await
109    }
110}
111
112/// Returns the host-supplied request context for the currently executing provider handler.
113pub fn current_request_context() -> Option<v1::RequestContext> {
114    REQUEST_CONTEXT.try_with(Clone::clone).ok().flatten()
115}
116
117/// Returns the current ambient request context in the generated clients'
118/// native form, for `with_context` on contextful clients such as
119/// [`App`](crate::App), [`Agent`](crate::Agent), and [`Workflow`](crate::Workflow).
120pub fn current_native_request_context() -> Option<crate::app::RequestContext> {
121    current_request_context().map(native_request_context)
122}
123
124fn native_request_context(value: v1::RequestContext) -> crate::app::RequestContext {
125    use crate::codec::app::{from_wire_agent_tool_ref, from_wire_subject_context};
126    use crate::codec::support::from_wire_struct;
127
128    crate::app::RequestContext {
129        subject: value.subject.map(from_wire_subject_context),
130        credential: value
131            .credential
132            .map(|credential| crate::app::CredentialContext {
133                mode: credential.mode,
134                subject_id: credential.subject_id,
135                connection: credential.connection,
136                instance: credential.instance,
137            }),
138        access: value.access.map(|access| crate::app::AccessContext {
139            policy: access.policy,
140            role: access.role,
141        }),
142        workflow: value.workflow.map(from_wire_struct),
143        host: value.host.map(|host| crate::app::HostContext {
144            public_base_url: host.public_base_url,
145        }),
146        agent_subject: value.agent_subject.map(from_wire_subject_context),
147        caller: value.caller.map(|caller| crate::app::ProviderContext {
148            kind: caller.kind,
149            name: caller.name,
150        }),
151        invocation: value
152            .invocation
153            .map(|invocation| crate::app::InvocationContext {
154                request_id: invocation.request_id,
155                depth: invocation.depth,
156                call_chain: invocation.call_chain,
157                surface: invocation.surface,
158                internal_connection_access: invocation.internal_connection_access,
159                connection: invocation.connection,
160            }),
161        tool_refs: value
162            .tool_refs
163            .into_iter()
164            .map(from_wire_agent_tool_ref)
165            .collect(),
166        tool_refs_set: value.tool_refs_set,
167        request_meta: value
168            .request_meta
169            .map(|meta| crate::app::RequestMetaContext {
170                client_ip: meta.client_ip,
171                remote_addr: meta.remote_addr,
172                user_agent: meta.user_agent,
173            }),
174        agent: value.agent.map(|agent| crate::app::AgentInvocationContext {
175            provider_name: agent.provider_name,
176            session_id: agent.session_id,
177            turn_id: agent.turn_id,
178        }),
179    }
180}
181
182/// Runs an async operation with the supplied request context available to Gestalt clients.
183pub async fn with_request_context<F>(context: Option<v1::RequestContext>, future: F) -> F::Output
184where
185    F: Future,
186{
187    REQUEST_CONTEXT.scope(context, future).await
188}
189
190pub(crate) async fn scope_request_context<F>(
191    context: Option<v1::RequestContext>,
192    future: F,
193) -> F::Output
194where
195    F: Future,
196{
197    with_request_context(context, future).await
198}
199
200#[derive(Clone, Debug, Default, PartialEq)]
201/// Carries one verified hosted HTTP request into a provider subject resolver.
202pub struct HTTPSubjectRequest {
203    /// Hosted HTTP binding name from the app manifest.
204    pub binding: String,
205    /// HTTP method used for the inbound request.
206    pub method: String,
207    /// Request path received by the hosted HTTP binding.
208    pub path: String,
209    /// Request content type.
210    pub content_type: String,
211    /// Request headers after host-side verification.
212    pub headers: BTreeMap<String, Vec<String>>,
213    /// Request query parameters.
214    pub query: BTreeMap<String, Vec<String>>,
215    /// Decoded request parameters.
216    pub params: serde_json::Map<String, serde_json::Value>,
217    /// Raw request body bytes.
218    pub raw_body: Vec<u8>,
219    /// Security scheme used to verify the request.
220    pub security_scheme: String,
221    /// Subject string verified by the security scheme, when available.
222    pub verified_subject: String,
223    /// Claims verified by the security scheme.
224    pub verified_claims: BTreeMap<String, String>,
225}
226
227#[derive(Clone, Debug, Eq, PartialEq)]
228/// Wraps a typed handler response plus an optional explicit HTTP status code.
229pub struct Response<T> {
230    /// Optional explicit HTTP-style status code.
231    pub status: Option<u16>,
232    /// HTTP response headers returned by the handler.
233    pub headers: BTreeMap<String, Vec<String>>,
234    /// Typed response body returned by the handler.
235    pub body: T,
236}
237
238impl<T> Response<T> {
239    /// Creates a response with an explicit HTTP status code.
240    pub fn new(status: u16, body: T) -> Self {
241        Self {
242            status: Some(status),
243            headers: BTreeMap::new(),
244            body,
245        }
246    }
247
248    /// Adds one HTTP response header value.
249    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
250        self.headers
251            .entry(name.into())
252            .or_default()
253            .push(value.into());
254        self
255    }
256}
257
258/// Returns a successful JSON response with status code `200`.
259pub fn ok<T>(body: T) -> Response<T> {
260    Response::new(200, body)
261}
262
263/// Converts handler return values into a typed [`Response`].
264pub trait IntoResponse<T> {
265    /// Converts a handler return value into a typed response wrapper.
266    fn into_response(self) -> Response<T>;
267}
268
269impl<T> IntoResponse<T> for Response<T> {
270    fn into_response(self) -> Response<T> {
271        self
272    }
273}
274
275impl<T> IntoResponse<T> for T {
276    fn into_response(self) -> Response<T> {
277        ok(self)
278    }
279}
280
281#[derive(Clone, Debug, Default, Eq, PartialEq)]
282/// Describes provider metadata that should be surfaced by the runtime.
283pub struct RuntimeMetadata {
284    /// Provider name to report to the host.
285    pub name: String,
286    /// Human-readable provider display name.
287    pub display_name: String,
288    /// Human-readable provider description.
289    pub description: String,
290    /// Provider version string.
291    pub version: String,
292}
293
294#[async_trait]
295/// Shared lifecycle contract for Gestalt integration providers.
296pub trait Provider: Send + Sync + 'static {
297    /// Configures the provider before it starts serving requests.
298    async fn configure(
299        &self,
300        _name: &str,
301        _config: serde_json::Map<String, serde_json::Value>,
302    ) -> Result<()> {
303        Ok(())
304    }
305
306    /// Returns runtime metadata that should augment the static manifest.
307    fn metadata(&self) -> Option<RuntimeMetadata> {
308        None
309    }
310
311    /// Returns non-fatal warnings the host should surface to users.
312    fn warnings(&self) -> Vec<String> {
313        Vec::new()
314    }
315
316    /// Performs an optional health check.
317    async fn health_check(&self) -> Result<()> {
318        Ok(())
319    }
320
321    /// Starts provider-owned background work after configuration.
322    async fn start(&self) -> Result<()> {
323        Ok(())
324    }
325
326    /// Reports whether this provider can derive additional operations from the
327    /// current request context.
328    fn supports_session_catalog(&self) -> bool {
329        false
330    }
331
332    /// Returns workflow definitions declared as static desired state.
333    fn workflow_definitions(&self) -> Vec<crate::workflow::WorkflowDefinitionSpec> {
334        Vec::new()
335    }
336
337    /// Returns an optional request-scoped catalog extension.
338    async fn catalog_for_request(&self, _request: &Request) -> Result<Option<Catalog>> {
339        Ok(None)
340    }
341
342    /// Resolves a hosted HTTP request to a concrete subject before dispatch.
343    async fn resolve_http_subject(
344        &self,
345        _request: HTTPSubjectRequest,
346        _context: &Request,
347    ) -> Result<Option<Subject>> {
348        Ok(None)
349    }
350
351    /// Shuts the provider down before the runtime exits.
352    async fn close(&self) -> Result<()> {
353        Ok(())
354    }
355}
356
357impl From<Infallible> for Error {
358    fn from(_value: Infallible) -> Self {
359        Error::internal("unreachable infallible error")
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::protocol;
367
368    #[tokio::test]
369    async fn converts_fully_populated_request_context() {
370        let wire = v1::RequestContext {
371            subject: Some(v1::SubjectContext {
372                id: "user:ada".to_string(),
373                email: "ada@example.test".to_string(),
374                display_name: "Ada".to_string(),
375                scopes: vec!["repo:read".to_string()],
376                permissions: vec![v1::SubjectPermissionContext {
377                    app: "github".to_string(),
378                    operations: vec!["issues.get".to_string()],
379                    all_operations: false,
380                }],
381            }),
382            credential: Some(v1::CredentialContext {
383                mode: "user".to_string(),
384                subject_id: "user:cred".to_string(),
385                connection: "work".to_string(),
386                instance: "primary".to_string(),
387            }),
388            access: Some(v1::AccessContext {
389                policy: "default".to_string(),
390                role: "admin".to_string(),
391            }),
392            workflow: Some(
393                protocol::struct_from_json(serde_json::json!({
394                    "runId": "run-1",
395                    "trigger": { "activationId": "act-1" },
396                }))
397                .expect("workflow struct"),
398            ),
399            host: Some(v1::HostContext {
400                public_base_url: "https://gestalt.example.test".to_string(),
401            }),
402            agent_subject: Some(v1::SubjectContext {
403                id: "agent:caller".to_string(),
404                ..Default::default()
405            }),
406            caller: Some(v1::ProviderContext {
407                kind: "app".to_string(),
408                name: "hermes".to_string(),
409            }),
410            invocation: Some(v1::InvocationContext {
411                request_id: "req-1".to_string(),
412                depth: 2,
413                call_chain: vec!["hermes".to_string(), "github".to_string()],
414                surface: "mcp".to_string(),
415                internal_connection_access: true,
416                connection: "work".to_string(),
417            }),
418            tool_refs: vec![v1::AgentToolRef {
419                app: "slack".to_string(),
420                operation: "chat.postMessage".to_string(),
421                connection: "workspace".to_string(),
422                instance: "primary".to_string(),
423                title: "Send Slack message".to_string(),
424                description: "Post a Slack message".to_string(),
425                credential_mode: "user".to_string(),
426                system: "slack".to_string(),
427                run_as: Some(v1::SubjectContext {
428                    id: "user:run-as".to_string(),
429                    ..Default::default()
430                }),
431            }],
432            tool_refs_set: true,
433            request_meta: Some(v1::RequestMetaContext {
434                client_ip: "203.0.113.7".to_string(),
435                remote_addr: "203.0.113.7:443".to_string(),
436                user_agent: "gestalt-test".to_string(),
437            }),
438            agent: Some(v1::AgentInvocationContext {
439                provider_name: "openai".to_string(),
440                session_id: "session-1".to_string(),
441                turn_id: "turn-1".to_string(),
442            }),
443        };
444
445        let native = with_request_context(Some(wire), async { current_native_request_context() })
446            .await
447            .expect("native context");
448
449        assert_eq!(
450            native,
451            crate::app::RequestContext {
452                subject: Some(crate::app::SubjectContext {
453                    id: "user:ada".to_string(),
454                    email: "ada@example.test".to_string(),
455                    display_name: "Ada".to_string(),
456                    scopes: vec!["repo:read".to_string()],
457                    permissions: vec![crate::app::SubjectPermissionContext {
458                        app: "github".to_string(),
459                        operations: vec!["issues.get".to_string()],
460                        all_operations: false,
461                    }],
462                }),
463                credential: Some(crate::app::CredentialContext {
464                    mode: "user".to_string(),
465                    subject_id: "user:cred".to_string(),
466                    connection: "work".to_string(),
467                    instance: "primary".to_string(),
468                }),
469                access: Some(crate::app::AccessContext {
470                    policy: "default".to_string(),
471                    role: "admin".to_string(),
472                }),
473                workflow: serde_json::json!({
474                    "runId": "run-1",
475                    "trigger": { "activationId": "act-1" },
476                })
477                .as_object()
478                .cloned(),
479                host: Some(crate::app::HostContext {
480                    public_base_url: "https://gestalt.example.test".to_string(),
481                }),
482                agent_subject: Some(crate::app::SubjectContext {
483                    id: "agent:caller".to_string(),
484                    ..Default::default()
485                }),
486                caller: Some(crate::app::ProviderContext {
487                    kind: "app".to_string(),
488                    name: "hermes".to_string(),
489                }),
490                invocation: Some(crate::app::InvocationContext {
491                    request_id: "req-1".to_string(),
492                    depth: 2,
493                    call_chain: vec!["hermes".to_string(), "github".to_string()],
494                    surface: "mcp".to_string(),
495                    internal_connection_access: true,
496                    connection: "work".to_string(),
497                }),
498                tool_refs: vec![crate::app::AgentToolRef {
499                    app: "slack".to_string(),
500                    operation: "chat.postMessage".to_string(),
501                    connection: "workspace".to_string(),
502                    instance: "primary".to_string(),
503                    title: "Send Slack message".to_string(),
504                    description: "Post a Slack message".to_string(),
505                    credential_mode: "user".to_string(),
506                    system: "slack".to_string(),
507                    run_as: Some(crate::app::SubjectContext {
508                        id: "user:run-as".to_string(),
509                        ..Default::default()
510                    }),
511                }],
512                tool_refs_set: true,
513                request_meta: Some(crate::app::RequestMetaContext {
514                    client_ip: "203.0.113.7".to_string(),
515                    remote_addr: "203.0.113.7:443".to_string(),
516                    user_agent: "gestalt-test".to_string(),
517                }),
518                agent: Some(crate::app::AgentInvocationContext {
519                    provider_name: "openai".to_string(),
520                    session_id: "session-1".to_string(),
521                    turn_id: "turn-1".to_string(),
522                }),
523            }
524        );
525    }
526
527    #[tokio::test]
528    async fn converts_sparse_request_context() {
529        let native = with_request_context(Some(v1::RequestContext::default()), async {
530            current_native_request_context()
531        })
532        .await
533        .expect("native context");
534
535        assert_eq!(native, crate::app::RequestContext::default());
536    }
537
538    #[test]
539    fn returns_none_outside_request_scope() {
540        assert!(current_native_request_context().is_none());
541    }
542}