Skip to main content

spicedb_rs_client/
lib.rs

1use spicedb_rs_proto::authzed::api::{
2    materialize::v0::{
3        watch_permission_sets_service_client::WatchPermissionSetsServiceClient,
4        watch_permissions_service_client::WatchPermissionsServiceClient,
5    },
6    v1::{
7        experimental_service_client::ExperimentalServiceClient,
8        permissions_service_client::PermissionsServiceClient,
9        schema_service_client::SchemaServiceClient, watch_service_client::WatchServiceClient,
10    },
11};
12use tonic::{
13    Request, Status,
14    metadata::{Ascii, MetadataValue, errors::InvalidMetadataValue},
15    service::{Interceptor, interceptor::InterceptedService},
16    transport::{Channel, Endpoint},
17};
18
19pub use spicedb_rs_proto::authzed::api::{materialize::v0, v1};
20
21#[derive(Debug, thiserror::Error)]
22pub enum ClientError {
23    #[error("invalid endpoint: {0}")]
24    InvalidEndpoint(#[from] http::uri::InvalidUri),
25    #[error("invalid token metadata: {0}")]
26    InvalidTokenMetadata(#[from] InvalidMetadataValue),
27    #[error("transport error: {0}")]
28    Transport(#[from] tonic::transport::Error),
29}
30
31#[doc(hidden)]
32#[derive(Clone, Debug)]
33pub struct AuthInterceptor {
34    authorization_header: Option<MetadataValue<Ascii>>,
35}
36
37impl AuthInterceptor {
38    fn from_token(token: Option<&str>) -> Result<Self, InvalidMetadataValue> {
39        // Token is optional to support test/local environments or callers that inject auth
40        // outside this client.
41        let authorization_header = token
42            .map(|token| format!("Bearer {token}").parse())
43            .transpose()?;
44        Ok(Self {
45            authorization_header,
46        })
47    }
48}
49
50impl Interceptor for AuthInterceptor {
51    fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
52        if let Some(authorization_header) = &self.authorization_header {
53            request
54                .metadata_mut()
55                .insert("authorization", authorization_header.clone());
56        }
57        Ok(request)
58    }
59}
60
61pub type InterceptedChannel = InterceptedService<Channel, AuthInterceptor>;
62
63#[derive(Debug, Clone)]
64pub struct ClientBuilder {
65    endpoint: String,
66    token: Option<String>,
67    insecure: bool,
68}
69
70impl Default for ClientBuilder {
71    fn default() -> Self {
72        Self {
73            endpoint: "grpc.authzed.com:443".to_string(),
74            token: None,
75            insecure: false,
76        }
77    }
78}
79
80impl ClientBuilder {
81    pub fn new(endpoint: impl Into<String>) -> Self {
82        Self {
83            endpoint: endpoint.into(),
84            ..Self::default()
85        }
86    }
87
88    /// Sets the Bearer token attached as `authorization` metadata.
89    ///
90    /// If omitted, requests are sent without an authorization header.
91    pub fn with_token(mut self, token: impl Into<String>) -> Self {
92        self.token = Some(token.into());
93        self
94    }
95
96    /// Toggles plaintext HTTP/2 transport (no TLS).
97    ///
98    /// This is typically useful for local development and integration tests.
99    pub fn insecure(mut self, insecure: bool) -> Self {
100        self.insecure = insecure;
101        self
102    }
103
104    /// Eagerly connects to SpiceDB and fails fast on transport errors.
105    pub async fn connect(self) -> Result<Client, ClientError> {
106        let endpoint = endpoint_with_scheme(&self.endpoint, self.insecure);
107        let channel = Endpoint::from_shared(endpoint)?.connect().await?;
108        build_client(channel, self.token.as_deref())
109    }
110
111    /// Builds a client lazily, matching official clients that dial on first request.
112    pub fn connect_lazy(self) -> Result<Client, ClientError> {
113        let endpoint = endpoint_with_scheme(&self.endpoint, self.insecure);
114        let channel = Endpoint::from_shared(endpoint)?.connect_lazy();
115        build_client(channel, self.token.as_deref())
116    }
117}
118
119fn endpoint_with_scheme(endpoint: &str, insecure: bool) -> String {
120    if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
121        endpoint.to_string()
122    } else if insecure {
123        format!("http://{endpoint}")
124    } else {
125        format!("https://{endpoint}")
126    }
127}
128
129fn build_client(channel: Channel, token: Option<&str>) -> Result<Client, ClientError> {
130    let interceptor = AuthInterceptor::from_token(token)?;
131
132    Ok(Client {
133        permissions: PermissionsServiceClient::with_interceptor(
134            channel.clone(),
135            interceptor.clone(),
136        ),
137        schema: SchemaServiceClient::with_interceptor(channel.clone(), interceptor.clone()),
138        watch: WatchServiceClient::with_interceptor(channel.clone(), interceptor.clone()),
139        experimental: ExperimentalServiceClient::with_interceptor(
140            channel.clone(),
141            interceptor.clone(),
142        ),
143        watch_permissions: WatchPermissionsServiceClient::with_interceptor(
144            channel.clone(),
145            interceptor.clone(),
146        ),
147        watch_permission_sets: WatchPermissionSetsServiceClient::with_interceptor(
148            channel,
149            interceptor,
150        ),
151    })
152}
153
154#[derive(Debug, Clone)]
155pub struct Client {
156    permissions: PermissionsServiceClient<InterceptedChannel>,
157    schema: SchemaServiceClient<InterceptedChannel>,
158    watch: WatchServiceClient<InterceptedChannel>,
159    experimental: ExperimentalServiceClient<InterceptedChannel>,
160    watch_permissions: WatchPermissionsServiceClient<InterceptedChannel>,
161    watch_permission_sets: WatchPermissionSetsServiceClient<InterceptedChannel>,
162}
163
164impl Client {
165    pub fn builder() -> ClientBuilder {
166        ClientBuilder::default()
167    }
168
169    /// Returns a cloned permissions client handle.
170    pub fn permissions(&self) -> PermissionsServiceClient<InterceptedChannel> {
171        self.permissions.clone()
172    }
173
174    /// Returns a cloned schema client handle.
175    pub fn schema(&self) -> SchemaServiceClient<InterceptedChannel> {
176        self.schema.clone()
177    }
178
179    /// Returns a cloned watch client handle.
180    pub fn watch(&self) -> WatchServiceClient<InterceptedChannel> {
181        self.watch.clone()
182    }
183
184    /// Returns a cloned experimental client handle.
185    pub fn experimental(&self) -> ExperimentalServiceClient<InterceptedChannel> {
186        self.experimental.clone()
187    }
188
189    /// Returns a cloned materialize watch-permissions client handle.
190    pub fn watch_permissions(&self) -> WatchPermissionsServiceClient<InterceptedChannel> {
191        self.watch_permissions.clone()
192    }
193
194    /// Returns a cloned materialize watch-permission-sets client handle.
195    pub fn watch_permission_sets(&self) -> WatchPermissionSetsServiceClient<InterceptedChannel> {
196        self.watch_permission_sets.clone()
197    }
198}