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