feat: Add working workspace #2
+131
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,64 @@
|
||||
[package]
|
||||
name = "syncode-collab"
|
||||
description = "SynCode collaboration plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
".",
|
||||
"crates/collab-api-graphql",
|
||||
"crates/collab-api-grpc",
|
||||
"crates/collab-application",
|
||||
"crates/collab-model",
|
||||
"crates/collab-store",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "MIT"
|
||||
repository = "https://syncode.sh/syncode/collab"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
[dependencies]
|
||||
async-graphql-axum = "7.2.1"
|
||||
axum = "0.8.9"
|
||||
clap = { version = "4.6.4", features = ["derive", "env"] }
|
||||
hex = "0.4.3"
|
||||
hmac = "0.13.0"
|
||||
serde_json = "1.0.149"
|
||||
sha2 = "0.11.0"
|
||||
syncode-collab-api-graphql = { path = "crates/collab-api-graphql" }
|
||||
syncode-collab-api-grpc = { path = "crates/collab-api-grpc" }
|
||||
syncode-collab-application = { path = "crates/collab-application" }
|
||||
syncode-collab-model = { path = "crates/collab-model" }
|
||||
syncode-collab-store = { path = "crates/collab-store" }
|
||||
thiserror = "2.0.19"
|
||||
tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread", "signal", "time"] }
|
||||
tonic = "0.14.6"
|
||||
|
||||
[dev-dependencies]
|
||||
syncode-collab-api-grpc = { path = "crates/collab-api-grpc" }
|
||||
tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread"] }
|
||||
tokio-stream = { version = "0.1.18", features = ["net"] }
|
||||
tonic = "0.14.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[package]
|
||||
name = "syncode-collab"
|
||||
description = "SynCode collaboration plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
".",
|
||||
"crates/collab-api-graphql",
|
||||
"crates/collab-api-grpc",
|
||||
"crates/collab-application",
|
||||
"crates/collab-model",
|
||||
"crates/collab-store",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "MIT"
|
||||
repository = "https://syncode.sh/syncode/collab"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
[dependencies]
|
||||
async-graphql = "7.2.1"
|
||||
async-graphql-axum = "7.2.1"
|
||||
axum = "0.8.9"
|
||||
clap = { version = "4.6.4", features = ["derive", "env"] }
|
||||
hex = "0.4.3"
|
||||
hmac = "0.13.0"
|
||||
serde_json = "1.0.149"
|
||||
sha2 = "0.11.0"
|
||||
syncode-collab-api-graphql = { path = "crates/collab-api-graphql" }
|
||||
syncode-collab-api-grpc = { path = "crates/collab-api-grpc" }
|
||||
syncode-collab-application = { path = "crates/collab-application" }
|
||||
syncode-collab-model = { path = "crates/collab-model" }
|
||||
syncode-collab-store = { path = "crates/collab-store" }
|
||||
thiserror = "2.0.19"
|
||||
tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread", "signal", "time"] }
|
||||
tonic = "0.14.6"
|
||||
tower-http = { version = "0.6.8", features = ["cors"] }
|
||||
uuid = { version = "1.24.0", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
syncode-collab-api-grpc = { path = "crates/collab-api-grpc" }
|
||||
tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread"] }
|
||||
tokio-stream = { version = "0.1.18", features = ["net"] }
|
||||
tonic = "0.14.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,44 +1,47 @@
|
||||
# syncode-collab
|
||||
|
||||
The SynCode collaboration plane. It is the authority for Workspaces, Projects,
|
||||
Issues, pull requests, review, Documents, Versions, webhooks, the activity feed
|
||||
and the Inbox.
|
||||
|
||||
Collaboration keeps stable `repository_id` and `principal_id` values but never
|
||||
joins another plane's database. Facts about refs, tags, principals and checks
|
||||
arrive as versioned events through a deduplicating inbox; decisions and state
|
||||
changes leave as commands over typed ports. There is no fallback path, no
|
||||
dual-write and no read of a foreign store.
|
||||
|
||||
## Layout
|
||||
|
||||
| Crate | Role |
|
||||
| --- | --- |
|
||||
| `collab-model` | domain types; free of transport, storage and runtime |
|
||||
| `collab-application` | use cases and ports; free of transport and storage |
|
||||
| `collab-store` | PostgreSQL state, migrations, inbox and outbox |
|
||||
| `collab-api-graphql` | typed GraphQL surface for the front |
|
||||
| `collab-api-grpc` | versioned gRPC surface for `syn` |
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
export SYNCODE_COLLAB_DATABASE_URL=postgres://collab:collab@localhost:5432/collab
|
||||
export SYNCODE_COLLAB_EVENT_SECRET=replace-with-a-shared-secret
|
||||
cargo run --bin syncode-collab
|
||||
```
|
||||
|
||||
Migrations are applied on start, so an empty database is a valid starting point.
|
||||
gRPC listens on `8300` and HTTP on `8301`.
|
||||
|
||||
## Contracts
|
||||
|
||||
`crates/collab-api-graphql/schema.graphql` is the committed GraphQL contract.
|
||||
Regenerate it after changing the schema and commit the result:
|
||||
|
||||
```sh
|
||||
cargo run --bin collab_schema > crates/collab-api-graphql/schema.graphql
|
||||
```
|
||||
|
||||
The gRPC contract lives in `crates/collab-api-grpc/proto/collab.proto` and is
|
||||
generated at build time. CI fails when either contract drifts from its source.
|
||||
# syncode-collab
|
||||
|
||||
The SynCode collaboration plane. It is the authority for Workspaces, Projects,
|
||||
Issues, pull requests, review, Documents, Versions, webhooks, the activity feed
|
||||
and the Inbox.
|
||||
|
||||
Collaboration keeps stable `repository_id` and `principal_id` values but never
|
||||
joins another plane's database. Facts about refs, tags, principals and checks
|
||||
arrive as versioned events through a deduplicating inbox; decisions and state
|
||||
changes leave as commands over typed ports. There is no fallback path, no
|
||||
dual-write and no read of a foreign store.
|
||||
|
||||
## Layout
|
||||
|
||||
| Crate | Role |
|
||||
| --- | --- |
|
||||
| `collab-model` | domain types; free of transport, storage and runtime |
|
||||
| `collab-application` | use cases and ports; free of transport and storage |
|
||||
| `collab-store` | PostgreSQL state, migrations, inbox and outbox |
|
||||
| `collab-api-graphql` | typed GraphQL surface for the front |
|
||||
| `collab-api-grpc` | versioned gRPC surface for `syn` |
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
export SYNCODE_COLLAB_DATABASE_URL=postgres://collab:collab@localhost:5432/collab
|
||||
export SYNCODE_COLLAB_EVENT_SECRET=replace-with-a-shared-secret
|
||||
export SYNCODE_IDENTITY_GRPC_URL=http://127.0.0.1:8190
|
||||
export SYNCODE_IDENTITY_SHARED_SECRET=replace-with-the-identity-secret
|
||||
export SYNCODE_COLLAB_CORS_ORIGINS=http://127.0.0.1:8081
|
||||
cargo run --bin syncode-collab
|
||||
```
|
||||
|
||||
Migrations are applied on start, so an empty database is a valid starting point.
|
||||
gRPC listens on `8300` and HTTP on `8301`.
|
||||
|
||||
## Contracts
|
||||
|
||||
`crates/collab-api-graphql/schema.graphql` is the committed GraphQL contract.
|
||||
Regenerate it after changing the schema and commit the result:
|
||||
|
||||
```sh
|
||||
cargo run --bin collab_schema > crates/collab-api-graphql/schema.graphql
|
||||
```
|
||||
|
||||
The gRPC contract lives in `crates/collab-api-grpc/proto/collab.proto` and is
|
||||
generated at build time. CI fails when either contract drifts from its source.
|
||||
@@ -1,32 +1,41 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
#[command(name = "syncode-collab", about = "SynCode collaboration plane")]
|
||||
pub struct Config {
|
||||
#[arg(
|
||||
long,
|
||||
env = "SYNCODE_COLLAB_LISTEN_GRPC",
|
||||
default_value = "0.0.0.0:8300"
|
||||
)]
|
||||
pub listen_grpc: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "SYNCODE_COLLAB_LISTEN_HTTP",
|
||||
default_value = "0.0.0.0:8301"
|
||||
)]
|
||||
pub listen_http: String,
|
||||
|
||||
#[arg(long, env = "SYNCODE_COLLAB_DATABASE_URL")]
|
||||
pub database_url: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "SYNCODE_COLLAB_DATABASE_MAX_CONNECTIONS",
|
||||
default_value_t = 16
|
||||
)]
|
||||
pub database_max_connections: u32,
|
||||
|
||||
#[arg(long, env = "SYNCODE_COLLAB_EVENT_SECRET", hide_env_values = true)]
|
||||
pub event_secret: String,
|
||||
}
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
#[command(name = "syncode-collab", about = "SynCode collaboration plane")]
|
||||
pub struct Config {
|
||||
#[arg(
|
||||
long,
|
||||
env = "SYNCODE_COLLAB_LISTEN_GRPC",
|
||||
default_value = "0.0.0.0:8300"
|
||||
)]
|
||||
pub listen_grpc: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "SYNCODE_COLLAB_LISTEN_HTTP",
|
||||
default_value = "0.0.0.0:8301"
|
||||
)]
|
||||
pub listen_http: String,
|
||||
|
||||
#[arg(long, env = "SYNCODE_COLLAB_DATABASE_URL")]
|
||||
pub database_url: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "SYNCODE_COLLAB_DATABASE_MAX_CONNECTIONS",
|
||||
default_value_t = 16
|
||||
)]
|
||||
pub database_max_connections: u32,
|
||||
|
||||
#[arg(long, env = "SYNCODE_COLLAB_EVENT_SECRET", hide_env_values = true)]
|
||||
pub event_secret: String,
|
||||
|
||||
#[arg(long, env = "SYNCODE_IDENTITY_GRPC_URL")]
|
||||
pub identity_grpc_url: String,
|
||||
|
||||
#[arg(long, env = "SYNCODE_IDENTITY_SHARED_SECRET", hide_env_values = true)]
|
||||
pub identity_shared_secret: String,
|
||||
|
||||
#[arg(long, env = "SYNCODE_COLLAB_CORS_ORIGINS", value_delimiter = ',')]
|
||||
pub cors_origins: Vec<String>,
|
||||
}
|
||||
+47
-6
@@ -1,97 +1,138 @@
|
||||
use async_graphql_axum::GraphQL;
|
||||
use axum::body::Bytes;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Router, extract::State};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::sync::Arc;
|
||||
use syncode_collab_application::{Acceptance, EventInbox, InboxError, StoreError, accept};
|
||||
use syncode_collab_model::Envelope;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HttpState {
|
||||
inbox: Arc<dyn EventInbox>,
|
||||
event_secret: String,
|
||||
}
|
||||
|
||||
pub fn router(inbox: Arc<dyn EventInbox>, event_secret: String) -> Router {
|
||||
Router::new()
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
.route("/events", post(receive_event))
|
||||
.route_service(
|
||||
"/graphql",
|
||||
GraphQL::new(syncode_collab_api_graphql::build()),
|
||||
)
|
||||
.with_state(HttpState {
|
||||
inbox,
|
||||
event_secret,
|
||||
})
|
||||
}
|
||||
|
||||
async fn receive_event(
|
||||
State(state): State<HttpState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
if !valid_signature(&headers, &body, &state.event_secret) {
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
let Ok(envelope) = serde_json::from_slice::<Envelope>(&body) else {
|
||||
return StatusCode::BAD_REQUEST;
|
||||
};
|
||||
if headers
|
||||
.get("x-syncode-event")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
!= Some(envelope.message_type.as_str())
|
||||
|| headers
|
||||
.get("x-syncode-delivery")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
!= Some(envelope.message_id.to_string().as_str())
|
||||
{
|
||||
return StatusCode::BAD_REQUEST;
|
||||
}
|
||||
match accept(state.inbox.as_ref(), &envelope).await {
|
||||
Ok(Acceptance::Applied) => StatusCode::NO_CONTENT,
|
||||
Ok(Acceptance::AlreadyApplied) => StatusCode::OK,
|
||||
Err(InboxError::Store(StoreError::Unavailable(_))) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Err(InboxError::Store(StoreError::Rejected(_))) => StatusCode::BAD_REQUEST,
|
||||
Err(_) => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_signature(headers: &HeaderMap, body: &[u8], secret: &str) -> bool {
|
||||
let Some(signature) = headers
|
||||
.get("x-syncode-signature")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| hex::decode(value).ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(body);
|
||||
mac.verify_slice(&signature).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verifies_the_raw_event_body() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let body = br#"{"message_id":"00000000-0000-0000-0000-000000000000"}"#;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(b"secret")?;
|
||||
mac.update(body);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-syncode-signature",
|
||||
hex::encode(mac.finalize().into_bytes()).parse()?,
|
||||
);
|
||||
|
||||
assert!(valid_signature(&headers, body, "secret"));
|
||||
assert!(!valid_signature(&headers, b"changed", "secret"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
|
||||
use axum::body::Bytes;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Router, extract::State};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::sync::Arc;
|
||||
use syncode_collab_application::Collaboration;
|
||||
use syncode_collab_application::{Acceptance, EventInbox, InboxError, StoreError, accept};
|
||||
use syncode_collab_model::Envelope;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HttpState {
|
||||
inbox: Arc<dyn EventInbox>,
|
||||
event_secret: String,
|
||||
schema: syncode_collab_api_graphql::CollabSchema,
|
||||
}
|
||||
|
||||
pub fn router(
|
||||
inbox: Arc<dyn EventInbox>,
|
||||
collaboration: Arc<Collaboration>,
|
||||
event_secret: String,
|
||||
) -> Router {
|
||||
Router::new()
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
.route("/events", post(receive_event))
|
||||
.route("/graphql", post(graphql))
|
||||
.with_state(HttpState {
|
||||
inbox,
|
||||
event_secret,
|
||||
schema: syncode_collab_api_graphql::build(collaboration),
|
||||
})
|
||||
}
|
||||
|
||||
async fn graphql(
|
||||
State(state): State<HttpState>,
|
||||
headers: HeaderMap,
|
||||
request: GraphQLRequest,
|
||||
) -> GraphQLResponse {
|
||||
let token = match session_token(&headers) {
|
||||
Ok(token) => token.map(ToOwned::to_owned),
|
||||
Err(()) => {
|
||||
return async_graphql::Response::from_errors(vec![async_graphql::ServerError::new(
|
||||
"invalid cookie header",
|
||||
None,
|
||||
)])
|
||||
.into();
|
||||
}
|
||||
};
|
||||
state
|
||||
.schema
|
||||
.execute(
|
||||
request
|
||||
.into_inner()
|
||||
.data(syncode_collab_api_graphql::SessionToken(token)),
|
||||
)
|
||||
.await
|
||||
.into()
|
||||
}
|
||||
|
||||
fn session_token(headers: &HeaderMap) -> Result<Option<&str>, ()> {
|
||||
let Some(value) = headers.get(axum::http::header::COOKIE) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = value.to_str().map_err(|_| ())?;
|
||||
Ok(value
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find_map(|pair| pair.strip_prefix("syncode_identity_session=")))
|
||||
}
|
||||
|
||||
async fn receive_event(
|
||||
State(state): State<HttpState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
if !valid_signature(&headers, &body, &state.event_secret) {
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
let Ok(envelope) = serde_json::from_slice::<Envelope>(&body) else {
|
||||
return StatusCode::BAD_REQUEST;
|
||||
};
|
||||
if headers
|
||||
.get("x-syncode-event")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
!= Some(envelope.message_type.as_str())
|
||||
|| headers
|
||||
.get("x-syncode-delivery")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
!= Some(envelope.message_id.to_string().as_str())
|
||||
{
|
||||
return StatusCode::BAD_REQUEST;
|
||||
}
|
||||
match accept(state.inbox.as_ref(), &envelope).await {
|
||||
Ok(Acceptance::Applied) => StatusCode::NO_CONTENT,
|
||||
Ok(Acceptance::AlreadyApplied) => StatusCode::OK,
|
||||
Err(InboxError::Store(StoreError::Unavailable(_))) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Err(InboxError::Store(StoreError::Rejected(_))) => StatusCode::BAD_REQUEST,
|
||||
Err(_) => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_signature(headers: &HeaderMap, body: &[u8], secret: &str) -> bool {
|
||||
let Some(signature) = headers
|
||||
.get("x-syncode-signature")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| hex::decode(value).ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(body);
|
||||
mac.verify_slice(&signature).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verifies_the_raw_event_body() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let body = br#"{"message_id":"00000000-0000-0000-0000-000000000000"}"#;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(b"secret")?;
|
||||
mac.update(body);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-syncode-signature",
|
||||
hex::encode(mac.finalize().into_bytes()).parse()?,
|
||||
);
|
||||
|
||||
assert!(valid_signature(&headers, body, "secret"));
|
||||
assert!(!valid_signature(&headers, b"changed", "secret"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod config;
|
||||
pub mod http;
|
||||
pub mod runtime;
|
||||
|
||||
pub use config::Config;
|
||||
pub use runtime::{RuntimeError, run};
|
||||
pub mod config;
|
||||
pub mod http;
|
||||
pub mod identity;
|
||||
pub mod runtime;
|
||||
|
||||
pub use config::Config;
|
||||
pub use runtime::{RuntimeError, run};
|
||||
+41
-3
@@ -1,54 +1,92 @@
|
||||
use std::sync::Arc;
|
||||
use syncode_collab_api_grpc::CollabService;
|
||||
use syncode_collab_store::{PostgresInbox, connect, migrate};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::http::router;
|
||||
|
||||
pub async fn run(config: Config) -> Result<(), RuntimeError> {
|
||||
if config.event_secret.is_empty() {
|
||||
return Err(RuntimeError::Configuration(
|
||||
"SYNCODE_COLLAB_EVENT_SECRET must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
let pool = connect(&config.database_url, config.database_max_connections)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::Store(error.to_string()))?;
|
||||
migrate(&pool)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::Store(error.to_string()))?;
|
||||
|
||||
let http = tokio::net::TcpListener::bind(&config.listen_http)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::Listen(config.listen_http.clone(), error.to_string()))?;
|
||||
let grpc = config
|
||||
.listen_grpc
|
||||
.parse()
|
||||
.map_err(|_| RuntimeError::Address(config.listen_grpc.clone()))?;
|
||||
|
||||
let inbox = Arc::new(PostgresInbox::new(pool));
|
||||
let serve_http = axum::serve(http, router(inbox, config.event_secret));
|
||||
let serve_grpc = tonic::transport::Server::builder()
|
||||
.add_service(CollabService.into_server())
|
||||
.serve(grpc);
|
||||
|
||||
tokio::select! {
|
||||
result = serve_http => result.map_err(|error| RuntimeError::Serve(error.to_string())),
|
||||
result = serve_grpc => result.map_err(|error| RuntimeError::Serve(error.to_string())),
|
||||
_ = tokio::signal::ctrl_c() => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RuntimeError {
|
||||
#[error("invalid collaboration configuration: {0}")]
|
||||
Configuration(String),
|
||||
#[error("{0} is not a valid socket address")]
|
||||
Address(String),
|
||||
#[error("cannot listen on {0}: {1}")]
|
||||
Listen(String, String),
|
||||
#[error("collaboration store failed: {0}")]
|
||||
Store(String),
|
||||
#[error("serving failed: {0}")]
|
||||
Serve(String),
|
||||
}
|
||||
use std::sync::Arc;
|
||||
use syncode_collab_api_grpc::CollabService;
|
||||
use syncode_collab_application::Collaboration;
|
||||
use syncode_collab_store::{PostgresCollaboration, PostgresInbox, connect, migrate};
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::http::router;
|
||||
use crate::identity::IdentityClientAccess;
|
||||
|
||||
pub async fn run(config: Config) -> Result<(), RuntimeError> {
|
||||
if config.event_secret.is_empty() {
|
||||
return Err(RuntimeError::Configuration(
|
||||
"SYNCODE_COLLAB_EVENT_SECRET must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
if config.identity_shared_secret.is_empty() {
|
||||
return Err(RuntimeError::Configuration(
|
||||
"SYNCODE_IDENTITY_SHARED_SECRET must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
let pool = connect(&config.database_url, config.database_max_connections)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::Store(error.to_string()))?;
|
||||
migrate(&pool)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::Store(error.to_string()))?;
|
||||
|
||||
let http = tokio::net::TcpListener::bind(&config.listen_http)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::Listen(config.listen_http.clone(), error.to_string()))?;
|
||||
let grpc = config
|
||||
.listen_grpc
|
||||
.parse()
|
||||
.map_err(|_| RuntimeError::Address(config.listen_grpc.clone()))?;
|
||||
|
||||
let identity = Arc::new(
|
||||
IdentityClientAccess::connect(config.identity_grpc_url, config.identity_shared_secret)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::Identity(error.to_string()))?,
|
||||
);
|
||||
let collaboration = Arc::new(Collaboration::new(
|
||||
Arc::new(PostgresCollaboration::new(pool.clone())),
|
||||
identity,
|
||||
));
|
||||
let inbox = Arc::new(PostgresInbox::new(pool));
|
||||
let mut http_router = router(inbox, Arc::clone(&collaboration), config.event_secret);
|
||||
if !config.cors_origins.is_empty() {
|
||||
let allowed = config.cors_origins;
|
||||
http_router = http_router.layer(
|
||||
CorsLayer::new()
|
||||
.allow_credentials(true)
|
||||
.allow_methods([
|
||||
axum::http::Method::GET,
|
||||
axum::http::Method::POST,
|
||||
axum::http::Method::OPTIONS,
|
||||
])
|
||||
.allow_headers([axum::http::header::CONTENT_TYPE])
|
||||
.allow_origin(AllowOrigin::predicate(move |origin, _| {
|
||||
origin
|
||||
.to_str()
|
||||
.is_ok_and(|origin| allowed.iter().any(|value| value == origin))
|
||||
})),
|
||||
);
|
||||
}
|
||||
let serve_http = axum::serve(http, http_router);
|
||||
let serve_grpc = tonic::transport::Server::builder()
|
||||
.add_service(CollabService::new(collaboration).into_server())
|
||||
.serve(grpc);
|
||||
|
||||
tokio::select! {
|
||||
result = serve_http => result.map_err(|error| RuntimeError::Serve(error.to_string())),
|
||||
result = serve_grpc => result.map_err(|error| RuntimeError::Serve(error.to_string())),
|
||||
_ = tokio::signal::ctrl_c() => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RuntimeError {
|
||||
#[error("invalid collaboration configuration: {0}")]
|
||||
Configuration(String),
|
||||
#[error("{0} is not a valid socket address")]
|
||||
Address(String),
|
||||
#[error("cannot listen on {0}: {1}")]
|
||||
Listen(String, String),
|
||||
#[error("collaboration store failed: {0}")]
|
||||
Store(String),
|
||||
#[error("identity integration failed: {0}")]
|
||||
Identity(String),
|
||||
#[error("serving failed: {0}")]
|
||||
Serve(String),
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
use syncode_collab_api_grpc::CollabService;
|
||||
use syncode_collab_api_grpc::proto::GetPlaneInfoRequest;
|
||||
use syncode_collab_api_grpc::proto::collab_client::CollabClient;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_stream::wrappers::TcpListenerStream;
|
||||
|
||||
/// The gRPC contract is generated from `collab.proto` at build time. This test
|
||||
/// proves the generated server and client agree and the service answers.
|
||||
#[tokio::test]
|
||||
async fn serves_plane_info() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let address = listener.local_addr()?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = tonic::transport::Server::builder()
|
||||
.add_service(CollabService.into_server())
|
||||
.serve_with_incoming(TcpListenerStream::new(listener))
|
||||
.await;
|
||||
});
|
||||
|
||||
let mut client = CollabClient::connect(format!("http://{address}")).await?;
|
||||
let info = client.get_plane_info(GetPlaneInfoRequest {}).await?;
|
||||
|
||||
assert_eq!(env!("CARGO_PKG_VERSION"), info.get_ref().version);
|
||||
assert_eq!(1, info.get_ref().envelope_protocol_version);
|
||||
Ok(())
|
||||
}
|
||||
use syncode_collab_api_grpc::CollabService;
|
||||
use syncode_collab_api_grpc::proto::GetPlaneInfoRequest;
|
||||
use syncode_collab_api_grpc::proto::collab_client::CollabClient;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_stream::wrappers::TcpListenerStream;
|
||||
|
||||
/// The gRPC contract is generated from `collab.proto` at build time. This test
|
||||
/// proves the generated server and client agree and the service answers.
|
||||
#[tokio::test]
|
||||
async fn serves_plane_info() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let address = listener.local_addr()?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = tonic::transport::Server::builder()
|
||||
.add_service(CollabService::default().into_server())
|
||||
.serve_with_incoming(TcpListenerStream::new(listener))
|
||||
.await;
|
||||
});
|
||||
|
||||
let mut client = CollabClient::connect(format!("http://{address}")).await?;
|
||||
let info = client.get_plane_info(GetPlaneInfoRequest {}).await?;
|
||||
|
||||
assert_eq!(env!("CARGO_PKG_VERSION"), info.get_ref().version);
|
||||
assert_eq!(1, info.get_ref().envelope_protocol_version);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
[package]
|
||||
name = "syncode-collab-api-graphql"
|
||||
description = "GraphQL surface of the SynCode collaboration plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
async-graphql = "7.2.1"
|
||||
async-graphql-axum = "7.2.1"
|
||||
axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] }
|
||||
syncode-collab-application = { path = "../collab-application" }
|
||||
syncode-collab-model = { path = "../collab-model" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[package]
|
||||
name = "syncode-collab-api-graphql"
|
||||
description = "GraphQL surface of the SynCode collaboration plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
async-graphql = "7.2.1"
|
||||
async-graphql-axum = "7.2.1"
|
||||
axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] }
|
||||
syncode-collab-application = { path = "../collab-application" }
|
||||
syncode-collab-model = { path = "../collab-model" }
|
||||
uuid = "1.24.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,23 +1,185 @@
|
||||
type Query {
|
||||
"""
|
||||
Version of the collaboration plane serving this schema.
|
||||
"""
|
||||
version: String!
|
||||
"""
|
||||
Protocol version of the cross-plane event envelope this service accepts.
|
||||
"""
|
||||
envelopeProtocolVersion: Int!
|
||||
}
|
||||
|
||||
"""
|
||||
Directs the executor to include this field or fragment only when the `if` argument is true.
|
||||
"""
|
||||
directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
|
||||
"""
|
||||
Directs the executor to skip this field or fragment when the `if` argument is true.
|
||||
"""
|
||||
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
|
||||
schema {
|
||||
query: Query
|
||||
}
|
||||
|
||||
input AttachRepositoryInput {
|
||||
workspaceId: ID!
|
||||
owner: String!
|
||||
name: String!
|
||||
role: RepositoryRole!
|
||||
branch: String!
|
||||
}
|
||||
|
||||
type Checkout {
|
||||
workspace: Workspace!
|
||||
environment: String!
|
||||
repositories: [CheckoutRepository!]!
|
||||
omittedRepositories: Int!
|
||||
}
|
||||
|
||||
type CheckoutRepository {
|
||||
repositoryId: ID!
|
||||
owner: String!
|
||||
name: String!
|
||||
role: RepositoryRole!
|
||||
branch: String!
|
||||
}
|
||||
|
||||
input ConfigureEnvironmentInput {
|
||||
workspaceId: ID!
|
||||
name: String!
|
||||
defaultSourceBranch: String!
|
||||
infraBranch: String
|
||||
}
|
||||
|
||||
input CreateIssueInput {
|
||||
projectId: ID!
|
||||
title: String!
|
||||
description: String! = ""
|
||||
priority: IssuePriority!
|
||||
}
|
||||
|
||||
input CreateProjectInput {
|
||||
workspaceId: ID!
|
||||
key: String!
|
||||
name: String!
|
||||
slug: String!
|
||||
description: String! = ""
|
||||
kind: ProjectKind!
|
||||
}
|
||||
|
||||
input CreateVersionInput {
|
||||
projectId: ID!
|
||||
name: String!
|
||||
slug: String!
|
||||
}
|
||||
|
||||
input CreateWorkspaceInput {
|
||||
ownerKind: OwnerKind!
|
||||
ownerId: ID!
|
||||
name: String!
|
||||
slug: String!
|
||||
defaultEnvironment: String!
|
||||
defaultSourceBranch: String!
|
||||
}
|
||||
|
||||
type Document {
|
||||
id: ID!
|
||||
workspaceId: ID!
|
||||
projectId: ID
|
||||
path: String!
|
||||
revisionId: ID!
|
||||
content: String!
|
||||
}
|
||||
|
||||
type Environment {
|
||||
name: String!
|
||||
defaultSourceBranch: String!
|
||||
infraBranch: String
|
||||
}
|
||||
|
||||
type Issue {
|
||||
id: ID!
|
||||
projectId: ID!
|
||||
number: Int!
|
||||
title: String!
|
||||
description: String!
|
||||
statusId: ID!
|
||||
typeId: ID!
|
||||
priority: IssuePriority!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
enum IssuePriority {
|
||||
NONE
|
||||
LOW
|
||||
NORMAL
|
||||
HIGH
|
||||
URGENT
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createWorkspace(input: CreateWorkspaceInput!): Workspace!
|
||||
attachRepository(input: AttachRepositoryInput!): Boolean!
|
||||
configureEnvironment(input: ConfigureEnvironmentInput!): Environment!
|
||||
createProject(input: CreateProjectInput!): Project!
|
||||
putDocument(input: PutDocumentInput!): Document!
|
||||
createIssue(input: CreateIssueInput!): Issue!
|
||||
createVersion(input: CreateVersionInput!): Version!
|
||||
}
|
||||
|
||||
enum OwnerKind {
|
||||
USER
|
||||
ORGANIZATION
|
||||
}
|
||||
|
||||
type Project {
|
||||
id: ID!
|
||||
workspaceId: ID!
|
||||
key: String!
|
||||
name: String!
|
||||
slug: String!
|
||||
description: String!
|
||||
kind: ProjectKind!
|
||||
lifecycle: String!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
enum ProjectKind {
|
||||
CONTINUOUS
|
||||
FINITE
|
||||
}
|
||||
|
||||
input PutDocumentInput {
|
||||
workspaceId: ID!
|
||||
projectId: ID
|
||||
path: String!
|
||||
content: String!
|
||||
baseRevisionId: ID
|
||||
}
|
||||
|
||||
type Query {
|
||||
version: String!
|
||||
envelopeProtocolVersion: Int!
|
||||
workspaces: [Workspace!]!
|
||||
workspace(id: ID!): Workspace!
|
||||
checkout(workspaceId: ID!, environment: String, partial: Boolean): Checkout!
|
||||
projects(workspaceId: ID!): [Project!]!
|
||||
environments(workspaceId: ID!): [Environment!]!
|
||||
documents(workspaceId: ID!, projectId: ID): [Document!]!
|
||||
issues(projectId: ID!): [Issue!]!
|
||||
versions(projectId: ID!): [Version!]!
|
||||
}
|
||||
|
||||
enum RepositoryRole {
|
||||
SOURCE
|
||||
INFRA
|
||||
}
|
||||
|
||||
type Version {
|
||||
id: ID!
|
||||
projectId: ID!
|
||||
name: String!
|
||||
slug: String!
|
||||
state: String!
|
||||
}
|
||||
|
||||
type Workspace {
|
||||
id: ID!
|
||||
ownerKind: OwnerKind!
|
||||
ownerId: ID!
|
||||
name: String!
|
||||
slug: String!
|
||||
defaultEnvironment: String!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
"""
|
||||
Directs the executor to include this field or fragment only when the `if` argument is true.
|
||||
"""
|
||||
directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
|
||||
"""
|
||||
Directs the executor to skip this field or fragment when the `if` argument is true.
|
||||
"""
|
||||
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
|
||||
schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
[package]
|
||||
name = "syncode-collab-api-grpc"
|
||||
description = "Versioned gRPC API of the SynCode collaboration plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
prost = "0.14.4"
|
||||
syncode-collab-application = { path = "../collab-application" }
|
||||
syncode-collab-model = { path = "../collab-model" }
|
||||
tonic = "0.14.6"
|
||||
tonic-prost = "0.14.6"
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.14.4"
|
||||
protoc-bin-vendored = "3.2.0"
|
||||
tonic-prost-build = "0.14.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[package]
|
||||
name = "syncode-collab-api-grpc"
|
||||
description = "Versioned gRPC API of the SynCode collaboration plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
prost = "0.14.4"
|
||||
syncode-collab-application = { path = "../collab-application" }
|
||||
syncode-collab-model = { path = "../collab-model" }
|
||||
tonic = "0.14.6"
|
||||
tonic-prost = "0.14.6"
|
||||
uuid = "1.24.0"
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.14.4"
|
||||
protoc-bin-vendored = "3.2.0"
|
||||
tonic-prost-build = "0.14.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::error::Error;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let mut config = prost_build::Config::new();
|
||||
config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?);
|
||||
tonic_prost_build::configure().compile_with_config(
|
||||
config,
|
||||
&["proto/collab.proto"],
|
||||
&["proto"],
|
||||
)?;
|
||||
println!("cargo:rerun-if-changed=proto");
|
||||
Ok(())
|
||||
}
|
||||
use std::error::Error;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let mut config = prost_build::Config::new();
|
||||
config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?);
|
||||
tonic_prost_build::configure().compile_with_config(
|
||||
config,
|
||||
&["proto/collab.proto", "proto/identity.proto"],
|
||||
&["proto"],
|
||||
)?;
|
||||
println!("cargo:rerun-if-changed=proto");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
[package]
|
||||
name = "syncode-collab-model"
|
||||
description = "Domain model of the SynCode collaboration plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0.19"
|
||||
uuid = { version = "1.24.0", features = ["serde", "v4"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[package]
|
||||
name = "syncode-collab-model"
|
||||
description = "Domain model of the SynCode collaboration plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0.19"
|
||||
uuid = { version = "1.24.0", features = ["serde", "v4"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,27 +1,28 @@
|
||||
[package]
|
||||
name = "syncode-collab-store"
|
||||
description = "PostgreSQL state store of the SynCode collaboration plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.89"
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.8.6", default-features = false, features = ["json", "macros", "migrate", "postgres", "runtime-tokio", "tls-rustls", "uuid"] }
|
||||
syncode-collab-application = { path = "../collab-application" }
|
||||
syncode-collab-model = { path = "../collab-model" }
|
||||
thiserror = "2.0.19"
|
||||
uuid = "1.24.0"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.8.6", default-features = false, features = ["postgres", "runtime-tokio", "tls-rustls"] }
|
||||
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
|
||||
uuid = { version = "1.24.0", features = ["v4"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[package]
|
||||
name = "syncode-collab-store"
|
||||
description = "PostgreSQL state store of the SynCode collaboration plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.89"
|
||||
chrono = "0.4.42"
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.8.6", default-features = false, features = ["chrono", "json", "macros", "migrate", "postgres", "runtime-tokio", "tls-rustls", "uuid"] }
|
||||
syncode-collab-application = { path = "../collab-application" }
|
||||
syncode-collab-model = { path = "../collab-model" }
|
||||
thiserror = "2.0.19"
|
||||
uuid = "1.24.0"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.8.6", default-features = false, features = ["postgres", "runtime-tokio", "tls-rustls"] }
|
||||
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
|
||||
uuid = { version = "1.24.0", features = ["v4"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,3 +1,7 @@
|
||||
pub mod schema;
|
||||
|
||||
pub use schema::{CollabSchema, build, sdl};
|
||||
mod context;
|
||||
mod mutation;
|
||||
mod query;
|
||||
pub mod schema;
|
||||
mod types;
|
||||
|
||||
pub use schema::{CollabSchema, SessionToken, build, sdl};
|
||||
@@ -1,26 +1,24 @@
|
||||
use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema};
|
||||
|
||||
pub type CollabSchema = Schema<Query, EmptyMutation, EmptySubscription>;
|
||||
|
||||
pub struct Query;
|
||||
|
||||
#[Object]
|
||||
impl Query {
|
||||
/// Version of the collaboration plane serving this schema.
|
||||
async fn version(&self) -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// Protocol version of the cross-plane event envelope this service accepts.
|
||||
async fn envelope_protocol_version(&self) -> u32 {
|
||||
syncode_collab_model::envelope::PROTOCOL_VERSION.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> CollabSchema {
|
||||
Schema::build(Query, EmptyMutation, EmptySubscription).finish()
|
||||
}
|
||||
|
||||
pub fn sdl() -> String {
|
||||
build().sdl()
|
||||
}
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_graphql::{EmptySubscription, Schema};
|
||||
use syncode_collab_application::Collaboration;
|
||||
|
||||
use crate::mutation::Mutation;
|
||||
use crate::query::Query;
|
||||
|
||||
pub type CollabSchema = Schema<Query, Mutation, EmptySubscription>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionToken(pub Option<String>);
|
||||
|
||||
pub fn build(collaboration: Arc<Collaboration>) -> CollabSchema {
|
||||
Schema::build(Query, Mutation, EmptySubscription)
|
||||
.data(collaboration)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn sdl() -> String {
|
||||
Schema::build(Query, Mutation, EmptySubscription)
|
||||
.finish()
|
||||
.sdl()
|
||||
}
|
||||
@@ -1,14 +1,245 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package syncode.collab.v1;
|
||||
|
||||
service Collab {
|
||||
rpc GetPlaneInfo(GetPlaneInfoRequest) returns (PlaneInfo);
|
||||
}
|
||||
|
||||
message GetPlaneInfoRequest {}
|
||||
|
||||
message PlaneInfo {
|
||||
string version = 1;
|
||||
uint32 envelope_protocol_version = 2;
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package syncode.collab.v1;
|
||||
|
||||
service Collab {
|
||||
rpc GetPlaneInfo(GetPlaneInfoRequest) returns (PlaneInfo);
|
||||
rpc CreateWorkspace(CreateWorkspaceRequest) returns (Workspace);
|
||||
rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse);
|
||||
rpc GetWorkspace(GetWorkspaceRequest) returns (Workspace);
|
||||
rpc AttachRepository(AttachRepositoryRequest) returns (OperationResponse);
|
||||
rpc GetCheckout(GetCheckoutRequest) returns (Checkout);
|
||||
rpc ConfigureEnvironment(ConfigureEnvironmentRequest) returns (Environment);
|
||||
rpc ListEnvironments(ListEnvironmentsRequest) returns (ListEnvironmentsResponse);
|
||||
rpc CreateProject(CreateProjectRequest) returns (Project);
|
||||
rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse);
|
||||
rpc PutDocument(PutDocumentRequest) returns (Document);
|
||||
rpc ListDocuments(ListDocumentsRequest) returns (ListDocumentsResponse);
|
||||
rpc CreateIssue(CreateIssueRequest) returns (Issue);
|
||||
rpc ListIssues(ListIssuesRequest) returns (ListIssuesResponse);
|
||||
rpc CreateVersion(CreateVersionRequest) returns (Version);
|
||||
rpc ListVersions(ListVersionsRequest) returns (ListVersionsResponse);
|
||||
}
|
||||
|
||||
message GetPlaneInfoRequest {}
|
||||
|
||||
message PlaneInfo {
|
||||
string version = 1;
|
||||
uint32 envelope_protocol_version = 2;
|
||||
}
|
||||
|
||||
enum OwnerKind {
|
||||
OWNER_KIND_UNSPECIFIED = 0;
|
||||
OWNER_KIND_USER = 1;
|
||||
OWNER_KIND_ORGANIZATION = 2;
|
||||
}
|
||||
|
||||
enum RepositoryRole {
|
||||
REPOSITORY_ROLE_UNSPECIFIED = 0;
|
||||
REPOSITORY_ROLE_SOURCE = 1;
|
||||
REPOSITORY_ROLE_INFRA = 2;
|
||||
}
|
||||
|
||||
enum ProjectKind {
|
||||
PROJECT_KIND_UNSPECIFIED = 0;
|
||||
PROJECT_KIND_CONTINUOUS = 1;
|
||||
PROJECT_KIND_FINITE = 2;
|
||||
}
|
||||
|
||||
enum IssuePriority {
|
||||
ISSUE_PRIORITY_UNSPECIFIED = 0;
|
||||
ISSUE_PRIORITY_NONE = 1;
|
||||
ISSUE_PRIORITY_LOW = 2;
|
||||
ISSUE_PRIORITY_NORMAL = 3;
|
||||
ISSUE_PRIORITY_HIGH = 4;
|
||||
ISSUE_PRIORITY_URGENT = 5;
|
||||
}
|
||||
|
||||
message Workspace {
|
||||
string id = 1;
|
||||
OwnerKind owner_kind = 2;
|
||||
string owner_id = 3;
|
||||
string name = 4;
|
||||
string slug = 5;
|
||||
string default_environment = 6;
|
||||
int64 version = 7;
|
||||
}
|
||||
|
||||
message CreateWorkspaceRequest {
|
||||
string session_token = 1;
|
||||
OwnerKind owner_kind = 2;
|
||||
string owner_id = 3;
|
||||
string name = 4;
|
||||
string slug = 5;
|
||||
string default_environment = 6;
|
||||
string default_source_branch = 7;
|
||||
}
|
||||
|
||||
message ListWorkspacesRequest { string session_token = 1; }
|
||||
message ListWorkspacesResponse { repeated Workspace workspaces = 1; }
|
||||
|
||||
message GetWorkspaceRequest {
|
||||
string session_token = 1;
|
||||
string workspace_id = 2;
|
||||
}
|
||||
|
||||
message AttachRepositoryRequest {
|
||||
string session_token = 1;
|
||||
string workspace_id = 2;
|
||||
string owner = 3;
|
||||
string name = 4;
|
||||
RepositoryRole role = 5;
|
||||
string branch = 6;
|
||||
}
|
||||
|
||||
message OperationResponse {}
|
||||
|
||||
message GetCheckoutRequest {
|
||||
string session_token = 1;
|
||||
string workspace_id = 2;
|
||||
string environment = 3;
|
||||
bool partial = 4;
|
||||
}
|
||||
|
||||
message CheckoutRepository {
|
||||
string repository_id = 1;
|
||||
string owner = 2;
|
||||
string name = 3;
|
||||
RepositoryRole role = 4;
|
||||
string branch = 5;
|
||||
}
|
||||
|
||||
message Checkout {
|
||||
Workspace workspace = 1;
|
||||
string environment = 2;
|
||||
repeated CheckoutRepository repositories = 3;
|
||||
uint32 omitted_repositories = 4;
|
||||
}
|
||||
|
||||
message Environment {
|
||||
string workspace_id = 1;
|
||||
string name = 2;
|
||||
string default_source_branch = 3;
|
||||
string infra_branch = 4;
|
||||
}
|
||||
|
||||
message ConfigureEnvironmentRequest {
|
||||
string session_token = 1;
|
||||
string workspace_id = 2;
|
||||
string name = 3;
|
||||
string default_source_branch = 4;
|
||||
string infra_branch = 5;
|
||||
}
|
||||
|
||||
message ListEnvironmentsRequest {
|
||||
string session_token = 1;
|
||||
string workspace_id = 2;
|
||||
}
|
||||
|
||||
message ListEnvironmentsResponse { repeated Environment environments = 1; }
|
||||
|
||||
message Project {
|
||||
string id = 1;
|
||||
string workspace_id = 2;
|
||||
string key = 3;
|
||||
string name = 4;
|
||||
string slug = 5;
|
||||
string description = 6;
|
||||
ProjectKind kind = 7;
|
||||
string lifecycle = 8;
|
||||
string default_status_id = 9;
|
||||
string completion_status_id = 10;
|
||||
int64 version = 11;
|
||||
}
|
||||
|
||||
message CreateProjectRequest {
|
||||
string session_token = 1;
|
||||
string workspace_id = 2;
|
||||
string key = 3;
|
||||
string name = 4;
|
||||
string slug = 5;
|
||||
string description = 6;
|
||||
ProjectKind kind = 7;
|
||||
}
|
||||
|
||||
message ListProjectsRequest {
|
||||
string session_token = 1;
|
||||
string workspace_id = 2;
|
||||
}
|
||||
|
||||
message ListProjectsResponse { repeated Project projects = 1; }
|
||||
|
||||
message Document {
|
||||
string id = 1;
|
||||
string workspace_id = 2;
|
||||
string project_id = 3;
|
||||
string path = 4;
|
||||
string revision_id = 5;
|
||||
string content = 6;
|
||||
}
|
||||
|
||||
message PutDocumentRequest {
|
||||
string session_token = 1;
|
||||
string workspace_id = 2;
|
||||
string project_id = 3;
|
||||
string path = 4;
|
||||
string content = 5;
|
||||
string base_revision_id = 6;
|
||||
}
|
||||
|
||||
message ListDocumentsRequest {
|
||||
string session_token = 1;
|
||||
string workspace_id = 2;
|
||||
string project_id = 3;
|
||||
}
|
||||
|
||||
message ListDocumentsResponse { repeated Document documents = 1; }
|
||||
|
||||
message Issue {
|
||||
string id = 1;
|
||||
string project_id = 2;
|
||||
int64 number = 3;
|
||||
string title = 4;
|
||||
string description = 5;
|
||||
string status_id = 6;
|
||||
string type_id = 7;
|
||||
IssuePriority priority = 8;
|
||||
int64 version = 9;
|
||||
}
|
||||
|
||||
message CreateIssueRequest {
|
||||
string session_token = 1;
|
||||
string project_id = 2;
|
||||
string title = 3;
|
||||
string description = 4;
|
||||
IssuePriority priority = 5;
|
||||
}
|
||||
|
||||
message ListIssuesRequest {
|
||||
string session_token = 1;
|
||||
string project_id = 2;
|
||||
}
|
||||
|
||||
message ListIssuesResponse { repeated Issue issues = 1; }
|
||||
|
||||
message Version {
|
||||
string id = 1;
|
||||
string project_id = 2;
|
||||
string name = 3;
|
||||
string slug = 4;
|
||||
string state = 5;
|
||||
}
|
||||
|
||||
message CreateVersionRequest {
|
||||
string session_token = 1;
|
||||
string project_id = 2;
|
||||
string name = 3;
|
||||
string slug = 4;
|
||||
}
|
||||
|
||||
message ListVersionsRequest {
|
||||
string session_token = 1;
|
||||
string project_id = 2;
|
||||
}
|
||||
|
||||
message ListVersionsResponse { repeated Version versions = 1; }
|
||||
@@ -1,8 +1,15 @@
|
||||
pub mod service;
|
||||
|
||||
pub mod proto {
|
||||
#![allow(clippy::doc_overindented_list_items)]
|
||||
tonic::include_proto!("syncode.collab.v1");
|
||||
}
|
||||
|
||||
pub use service::CollabService;
|
||||
mod mapping;
|
||||
mod operations;
|
||||
pub mod service;
|
||||
|
||||
pub mod proto {
|
||||
#![allow(clippy::doc_overindented_list_items)]
|
||||
tonic::include_proto!("syncode.collab.v1");
|
||||
}
|
||||
|
||||
pub mod identity_proto {
|
||||
#![allow(clippy::doc_overindented_list_items)]
|
||||
tonic::include_proto!("syncode.identity.v1");
|
||||
}
|
||||
|
||||
pub use service::CollabService;
|
||||
@@ -1,26 +1,179 @@
|
||||
use tonic::{Request, Response, Status};
|
||||
|
||||
use crate::proto::collab_server::{Collab, CollabServer};
|
||||
use crate::proto::{GetPlaneInfoRequest, PlaneInfo};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CollabService;
|
||||
|
||||
impl CollabService {
|
||||
pub fn into_server(self) -> CollabServer<Self> {
|
||||
CollabServer::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Collab for CollabService {
|
||||
async fn get_plane_info(
|
||||
&self,
|
||||
_: Request<GetPlaneInfoRequest>,
|
||||
) -> Result<Response<PlaneInfo>, Status> {
|
||||
Ok(Response::new(PlaneInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
envelope_protocol_version: syncode_collab_model::envelope::PROTOCOL_VERSION.0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
use std::sync::Arc;
|
||||
|
||||
use tonic::{Request, Response, Status};
|
||||
|
||||
use crate::operations;
|
||||
use crate::proto::collab_server::{Collab, CollabServer};
|
||||
use crate::proto::*;
|
||||
use syncode_collab_application::Collaboration;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CollabService {
|
||||
collaboration: Option<Arc<Collaboration>>,
|
||||
}
|
||||
|
||||
impl CollabService {
|
||||
pub fn new(collaboration: Arc<Collaboration>) -> Self {
|
||||
Self {
|
||||
collaboration: Some(collaboration),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_server(self) -> CollabServer<Self> {
|
||||
CollabServer::new(self)
|
||||
}
|
||||
|
||||
fn collaboration(&self) -> Result<&Collaboration, Status> {
|
||||
self.collaboration
|
||||
.as_deref()
|
||||
.ok_or_else(|| Status::unavailable("collaboration application is not configured"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Collab for CollabService {
|
||||
async fn get_plane_info(
|
||||
&self,
|
||||
_: Request<GetPlaneInfoRequest>,
|
||||
) -> Result<Response<PlaneInfo>, Status> {
|
||||
Ok(Response::new(PlaneInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
envelope_protocol_version: syncode_collab_model::envelope::PROTOCOL_VERSION.0,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn create_workspace(
|
||||
&self,
|
||||
request: Request<CreateWorkspaceRequest>,
|
||||
) -> Result<Response<Workspace>, Status> {
|
||||
operations::create_workspace(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn list_workspaces(
|
||||
&self,
|
||||
request: Request<ListWorkspacesRequest>,
|
||||
) -> Result<Response<ListWorkspacesResponse>, Status> {
|
||||
operations::list_workspaces(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn get_workspace(
|
||||
&self,
|
||||
request: Request<GetWorkspaceRequest>,
|
||||
) -> Result<Response<Workspace>, Status> {
|
||||
operations::get_workspace(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn attach_repository(
|
||||
&self,
|
||||
request: Request<AttachRepositoryRequest>,
|
||||
) -> Result<Response<OperationResponse>, Status> {
|
||||
operations::attach_repository(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn get_checkout(
|
||||
&self,
|
||||
request: Request<GetCheckoutRequest>,
|
||||
) -> Result<Response<Checkout>, Status> {
|
||||
operations::get_checkout(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn configure_environment(
|
||||
&self,
|
||||
request: Request<ConfigureEnvironmentRequest>,
|
||||
) -> Result<Response<Environment>, Status> {
|
||||
operations::configure_environment(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn list_environments(
|
||||
&self,
|
||||
request: Request<ListEnvironmentsRequest>,
|
||||
) -> Result<Response<ListEnvironmentsResponse>, Status> {
|
||||
operations::list_environments(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn create_project(
|
||||
&self,
|
||||
request: Request<CreateProjectRequest>,
|
||||
) -> Result<Response<Project>, Status> {
|
||||
operations::create_project(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn list_projects(
|
||||
&self,
|
||||
request: Request<ListProjectsRequest>,
|
||||
) -> Result<Response<ListProjectsResponse>, Status> {
|
||||
operations::list_projects(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn put_document(
|
||||
&self,
|
||||
request: Request<PutDocumentRequest>,
|
||||
) -> Result<Response<Document>, Status> {
|
||||
operations::put_document(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn list_documents(
|
||||
&self,
|
||||
request: Request<ListDocumentsRequest>,
|
||||
) -> Result<Response<ListDocumentsResponse>, Status> {
|
||||
operations::list_documents(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn create_issue(
|
||||
&self,
|
||||
request: Request<CreateIssueRequest>,
|
||||
) -> Result<Response<Issue>, Status> {
|
||||
operations::create_issue(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn list_issues(
|
||||
&self,
|
||||
request: Request<ListIssuesRequest>,
|
||||
) -> Result<Response<ListIssuesResponse>, Status> {
|
||||
operations::list_issues(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn create_version(
|
||||
&self,
|
||||
request: Request<CreateVersionRequest>,
|
||||
) -> Result<Response<Version>, Status> {
|
||||
operations::create_version(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
|
||||
async fn list_versions(
|
||||
&self,
|
||||
request: Request<ListVersionsRequest>,
|
||||
) -> Result<Response<ListVersionsResponse>, Status> {
|
||||
operations::list_versions(self.collaboration()?, request.into_inner())
|
||||
.await
|
||||
.map(Response::new)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
pub mod inbox;
|
||||
pub mod outbox;
|
||||
pub mod ports;
|
||||
|
||||
pub use inbox::{Acceptance, InboxError, accept};
|
||||
pub use outbox::{
|
||||
DispatchReport, EventPublisher, OutboxMessage, OutboxQueue, PublishError, backoff, dispatch,
|
||||
};
|
||||
pub use ports::{EventInbox, EventOutbox, OutboxRecord, StoreError};
|
||||
pub mod inbox;
|
||||
pub mod outbox;
|
||||
pub mod ports;
|
||||
mod validation;
|
||||
mod working_types;
|
||||
mod working_versions;
|
||||
mod working_workspace;
|
||||
|
||||
pub use inbox::{Acceptance, InboxError, accept};
|
||||
pub use outbox::{
|
||||
DispatchReport, EventPublisher, OutboxMessage, OutboxQueue, PublishError, backoff, dispatch,
|
||||
};
|
||||
pub use ports::{EventInbox, EventOutbox, OutboxRecord, StoreError};
|
||||
pub use working_types::{
|
||||
CollaborationError, ConfigureEnvironment, CreateDocument, CreateIssue, CreateProject,
|
||||
CreateVersion, CreateWorkspace, Principal, PrincipalKind, RepositoryCoordinates,
|
||||
};
|
||||
pub use working_workspace::Collaboration;
|
||||
@@ -1,39 +1,188 @@
|
||||
use async_trait::async_trait;
|
||||
use syncode_collab_model::Envelope;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OutboxRecord {
|
||||
pub message_id: Uuid,
|
||||
pub message_type: String,
|
||||
pub resource_id: Uuid,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Records a foreign fact exactly once. The implementation must write the
|
||||
/// deduplication key in the same transaction as the effect it applies.
|
||||
#[async_trait]
|
||||
pub trait EventInbox: Send + Sync {
|
||||
async fn already_applied(&self, message_id: Uuid) -> Result<bool, StoreError>;
|
||||
|
||||
async fn last_position(
|
||||
&self,
|
||||
source_node_id: &str,
|
||||
resource_id: Uuid,
|
||||
) -> Result<Option<(i64, i64)>, StoreError>;
|
||||
|
||||
async fn apply(&self, envelope: &Envelope) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait EventOutbox: Send + Sync {
|
||||
async fn enqueue(&self, record: OutboxRecord) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
#[error("collaboration store is unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
#[error("collaboration store rejected the write: {0}")]
|
||||
Rejected(String),
|
||||
}
|
||||
use async_trait::async_trait;
|
||||
use syncode_collab_model::{
|
||||
Document, DocumentRevision, Envelope, Environment, Issue, Project, RepositoryRole, Version,
|
||||
Workspace,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OutboxRecord {
|
||||
pub message_id: Uuid,
|
||||
pub message_type: String,
|
||||
pub resource_id: Uuid,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Records a foreign fact exactly once. The implementation must write the
|
||||
/// deduplication key in the same transaction as the effect it applies.
|
||||
#[async_trait]
|
||||
pub trait EventInbox: Send + Sync {
|
||||
async fn already_applied(&self, message_id: Uuid) -> Result<bool, StoreError>;
|
||||
|
||||
async fn last_position(
|
||||
&self,
|
||||
source_node_id: &str,
|
||||
resource_id: Uuid,
|
||||
) -> Result<Option<(i64, i64)>, StoreError>;
|
||||
|
||||
async fn apply(&self, envelope: &Envelope) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait EventOutbox: Send + Sync {
|
||||
async fn enqueue(&self, record: OutboxRecord) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait IdentityAccess: Send + Sync {
|
||||
async fn authenticate(&self, session_token: &str) -> Result<crate::Principal, AccessError>;
|
||||
|
||||
async fn require_organization_admin(
|
||||
&self,
|
||||
principal: crate::Principal,
|
||||
organization_id: Uuid,
|
||||
) -> Result<(), AccessError>;
|
||||
|
||||
async fn resolve_readable_repository(
|
||||
&self,
|
||||
principal: crate::Principal,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
) -> Result<crate::RepositoryCoordinates, AccessError>;
|
||||
|
||||
async fn repository_coordinates(
|
||||
&self,
|
||||
principal: crate::Principal,
|
||||
repository_id: Uuid,
|
||||
) -> Result<crate::RepositoryCoordinates, AccessError>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StoredRepository {
|
||||
pub repository_id: Uuid,
|
||||
pub role: RepositoryRole,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait CollaborationStore: Send + Sync {
|
||||
async fn create_workspace(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &crate::CreateWorkspace,
|
||||
) -> Result<Workspace, StoreError>;
|
||||
|
||||
async fn list_workspaces(&self, principal_id: Uuid) -> Result<Vec<Workspace>, StoreError>;
|
||||
|
||||
async fn workspace(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Workspace, StoreError>;
|
||||
|
||||
async fn attach_repository(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
role: RepositoryRole,
|
||||
branch: &str,
|
||||
) -> Result<(), StoreError>;
|
||||
|
||||
async fn repositories(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<StoredRepository>, StoreError>;
|
||||
|
||||
async fn environment(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
name: &str,
|
||||
) -> Result<Environment, StoreError>;
|
||||
|
||||
async fn configure_environment(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &crate::ConfigureEnvironment,
|
||||
) -> Result<Environment, StoreError>;
|
||||
|
||||
async fn list_environments(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<Environment>, StoreError>;
|
||||
|
||||
async fn create_project(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &crate::CreateProject,
|
||||
) -> Result<Project, StoreError>;
|
||||
|
||||
async fn list_projects(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<Project>, StoreError>;
|
||||
|
||||
async fn write_document(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &crate::CreateDocument,
|
||||
) -> Result<(Document, DocumentRevision), StoreError>;
|
||||
|
||||
async fn list_documents(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
project_id: Option<Uuid>,
|
||||
) -> Result<Vec<(Document, DocumentRevision)>, StoreError>;
|
||||
|
||||
async fn create_issue(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &crate::CreateIssue,
|
||||
) -> Result<Issue, StoreError>;
|
||||
|
||||
async fn list_issues(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Issue>, StoreError>;
|
||||
|
||||
async fn create_version(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &crate::CreateVersion,
|
||||
) -> Result<Version, StoreError>;
|
||||
|
||||
async fn list_versions(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Version>, StoreError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AccessError {
|
||||
#[error("session is not authenticated")]
|
||||
Unauthenticated,
|
||||
#[error("access is denied")]
|
||||
Denied,
|
||||
#[error("identity resource was not found")]
|
||||
NotFound,
|
||||
#[error("identity is unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
#[error("collaboration store is unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
#[error("collaboration store rejected the write: {0}")]
|
||||
Rejected(String),
|
||||
#[error("collaboration resource was not found")]
|
||||
NotFound,
|
||||
#[error("collaboration write conflicts with current state: {0}")]
|
||||
Conflict(String),
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
pub mod envelope;
|
||||
pub mod plane;
|
||||
|
||||
pub use envelope::{Envelope, EnvelopeError, MessageType, ProtocolVersion, SchemaVersion};
|
||||
pub use plane::Plane;
|
||||
pub mod document;
|
||||
pub mod envelope;
|
||||
pub mod issue;
|
||||
pub mod plane;
|
||||
pub mod project;
|
||||
pub mod version;
|
||||
pub mod workspace;
|
||||
|
||||
pub use document::{Document, DocumentRevision, DocumentScope};
|
||||
pub use envelope::{Envelope, EnvelopeError, MessageType, ProtocolVersion, SchemaVersion};
|
||||
pub use issue::{Issue, IssuePriority};
|
||||
pub use plane::Plane;
|
||||
pub use project::{
|
||||
IssueStatus, IssueStatusCategory, IssueType, Project, ProjectKind, ProjectLifecycle,
|
||||
};
|
||||
pub use version::{Version, VersionState};
|
||||
pub use workspace::{
|
||||
Checkout, CheckoutRepository, Environment, OwnerKind, RepositoryRole, Workspace,
|
||||
WorkspaceRepository,
|
||||
};
|
||||
@@ -1,7 +1,16 @@
|
||||
pub mod inbox;
|
||||
pub mod outbox;
|
||||
pub mod pool;
|
||||
|
||||
pub use inbox::PostgresInbox;
|
||||
pub use outbox::PostgresOutbox;
|
||||
pub use pool::{PoolError, connect, migrate};
|
||||
pub mod inbox;
|
||||
pub mod outbox;
|
||||
pub mod pool;
|
||||
mod working;
|
||||
mod working_documents;
|
||||
mod working_environments;
|
||||
mod working_issues;
|
||||
mod working_projects;
|
||||
mod working_rows;
|
||||
mod working_versions;
|
||||
mod working_workspaces;
|
||||
|
||||
pub use inbox::PostgresInbox;
|
||||
pub use outbox::PostgresOutbox;
|
||||
pub use pool::{PoolError, connect, migrate};
|
||||
pub use working::PostgresCollaboration;
|
||||
@@ -1,0 +1,48 @@
|
||||
use async_graphql::{Context, ErrorExtensions, ID};
|
||||
use syncode_collab_application::ports::{AccessError, StoreError};
|
||||
use syncode_collab_application::{Collaboration, CollaborationError, Principal};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::schema::SessionToken;
|
||||
|
||||
pub(crate) async fn principal(ctx: &Context<'_>) -> async_graphql::Result<Principal> {
|
||||
let app = ctx.data::<std::sync::Arc<Collaboration>>()?;
|
||||
let token = ctx
|
||||
.data::<SessionToken>()?
|
||||
.0
|
||||
.as_deref()
|
||||
.ok_or_else(|| error(CollaborationError::Access(AccessError::Unauthenticated)))?;
|
||||
app.authenticate(token).await.map_err(error)
|
||||
}
|
||||
|
||||
pub(crate) fn app<'a>(ctx: &'a Context<'_>) -> async_graphql::Result<&'a Collaboration> {
|
||||
Ok(ctx.data::<std::sync::Arc<Collaboration>>()?.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn id(value: &ID, field: &str) -> async_graphql::Result<Uuid> {
|
||||
value
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|_| async_graphql::Error::new(format!("{field} must be a UUID")))
|
||||
}
|
||||
|
||||
pub(crate) fn optional_id(value: Option<&ID>, field: &str) -> async_graphql::Result<Option<Uuid>> {
|
||||
value.map(|value| id(value, field)).transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn error(error: CollaborationError) -> async_graphql::Error {
|
||||
let code = match &error {
|
||||
CollaborationError::Access(AccessError::Unauthenticated) => "UNAUTHENTICATED",
|
||||
CollaborationError::Access(AccessError::Denied) => "FORBIDDEN",
|
||||
CollaborationError::Access(AccessError::NotFound)
|
||||
| CollaborationError::Store(StoreError::NotFound) => "NOT_FOUND",
|
||||
CollaborationError::Store(StoreError::Conflict(_)) => "CONFLICT",
|
||||
CollaborationError::Invalid(_) => "BAD_USER_INPUT",
|
||||
CollaborationError::Access(AccessError::Unavailable(_))
|
||||
| CollaborationError::Store(StoreError::Unavailable(_)) => "UNAVAILABLE",
|
||||
CollaborationError::Store(StoreError::Rejected(_)) => "FAILED_PRECONDITION",
|
||||
};
|
||||
async_graphql::Error::new(error.to_string()).extend_with(|_, extensions| {
|
||||
extensions.set("code", code);
|
||||
})
|
||||
}
|
||||
@@ -1,0 +1,248 @@
|
||||
use async_graphql::{Context, ID, InputObject, Object};
|
||||
use syncode_collab_application::{
|
||||
ConfigureEnvironment, CreateDocument, CreateIssue, CreateProject, CreateVersion,
|
||||
CreateWorkspace,
|
||||
};
|
||||
use syncode_collab_model as model;
|
||||
|
||||
use crate::context;
|
||||
use crate::types::{
|
||||
Document, Environment, Issue, IssuePriority, OwnerKind, Project, ProjectKind, RepositoryRole,
|
||||
Version, Workspace,
|
||||
};
|
||||
|
||||
#[derive(InputObject)]
|
||||
struct CreateWorkspaceInput {
|
||||
owner_kind: OwnerKind,
|
||||
owner_id: ID,
|
||||
name: String,
|
||||
slug: String,
|
||||
default_environment: String,
|
||||
default_source_branch: String,
|
||||
}
|
||||
|
||||
#[derive(InputObject)]
|
||||
struct AttachRepositoryInput {
|
||||
workspace_id: ID,
|
||||
owner: String,
|
||||
name: String,
|
||||
role: RepositoryRole,
|
||||
branch: String,
|
||||
}
|
||||
|
||||
#[derive(InputObject)]
|
||||
struct ConfigureEnvironmentInput {
|
||||
workspace_id: ID,
|
||||
name: String,
|
||||
default_source_branch: String,
|
||||
infra_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(InputObject)]
|
||||
struct CreateProjectInput {
|
||||
workspace_id: ID,
|
||||
key: String,
|
||||
name: String,
|
||||
slug: String,
|
||||
#[graphql(default)]
|
||||
description: String,
|
||||
kind: ProjectKind,
|
||||
}
|
||||
|
||||
#[derive(InputObject)]
|
||||
struct PutDocumentInput {
|
||||
workspace_id: ID,
|
||||
project_id: Option<ID>,
|
||||
path: String,
|
||||
content: String,
|
||||
base_revision_id: Option<ID>,
|
||||
}
|
||||
|
||||
#[derive(InputObject)]
|
||||
struct CreateIssueInput {
|
||||
project_id: ID,
|
||||
title: String,
|
||||
#[graphql(default)]
|
||||
description: String,
|
||||
priority: IssuePriority,
|
||||
}
|
||||
|
||||
#[derive(InputObject)]
|
||||
struct CreateVersionInput {
|
||||
project_id: ID,
|
||||
name: String,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
pub struct Mutation;
|
||||
|
||||
#[Object]
|
||||
impl Mutation {
|
||||
async fn create_workspace(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
input: CreateWorkspaceInput,
|
||||
) -> async_graphql::Result<Workspace> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.create_workspace(
|
||||
principal,
|
||||
CreateWorkspace {
|
||||
owner_kind: match input.owner_kind {
|
||||
OwnerKind::User => model::OwnerKind::User,
|
||||
OwnerKind::Organization => model::OwnerKind::Organization,
|
||||
},
|
||||
owner_id: context::id(&input.owner_id, "owner id")?,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
default_environment: input.default_environment,
|
||||
default_source_branch: input.default_source_branch,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn attach_repository(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
input: AttachRepositoryInput,
|
||||
) -> async_graphql::Result<bool> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.attach_repository(
|
||||
principal,
|
||||
context::id(&input.workspace_id, "workspace id")?,
|
||||
&input.owner,
|
||||
&input.name,
|
||||
match input.role {
|
||||
RepositoryRole::Source => model::RepositoryRole::Source,
|
||||
RepositoryRole::Infra => model::RepositoryRole::Infra,
|
||||
},
|
||||
&input.branch,
|
||||
)
|
||||
.await
|
||||
.map(|()| true)
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn configure_environment(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
input: ConfigureEnvironmentInput,
|
||||
) -> async_graphql::Result<Environment> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.configure_environment(
|
||||
principal,
|
||||
ConfigureEnvironment {
|
||||
workspace_id: context::id(&input.workspace_id, "workspace id")?,
|
||||
name: input.name,
|
||||
default_source_branch: input.default_source_branch,
|
||||
infra_branch: input.infra_branch,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn create_project(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
input: CreateProjectInput,
|
||||
) -> async_graphql::Result<Project> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.create_project(
|
||||
principal,
|
||||
CreateProject {
|
||||
workspace_id: context::id(&input.workspace_id, "workspace id")?,
|
||||
key: input.key,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
kind: match input.kind {
|
||||
ProjectKind::Continuous => model::ProjectKind::Continuous,
|
||||
ProjectKind::Finite => model::ProjectKind::Finite,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn put_document(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
input: PutDocumentInput,
|
||||
) -> async_graphql::Result<Document> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.write_document(
|
||||
principal,
|
||||
CreateDocument {
|
||||
workspace_id: context::id(&input.workspace_id, "workspace id")?,
|
||||
project_id: context::optional_id(input.project_id.as_ref(), "project id")?,
|
||||
path: input.path,
|
||||
content: input.content,
|
||||
base_revision_id: context::optional_id(
|
||||
input.base_revision_id.as_ref(),
|
||||
"base revision id",
|
||||
)?,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn create_issue(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
input: CreateIssueInput,
|
||||
) -> async_graphql::Result<Issue> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.create_issue(
|
||||
principal,
|
||||
CreateIssue {
|
||||
project_id: context::id(&input.project_id, "project id")?,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
priority: match input.priority {
|
||||
IssuePriority::None => model::IssuePriority::None,
|
||||
IssuePriority::Low => model::IssuePriority::Low,
|
||||
IssuePriority::Normal => model::IssuePriority::Normal,
|
||||
IssuePriority::High => model::IssuePriority::High,
|
||||
IssuePriority::Urgent => model::IssuePriority::Urgent,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn create_version(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
input: CreateVersionInput,
|
||||
) -> async_graphql::Result<Version> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.create_version(
|
||||
principal,
|
||||
CreateVersion {
|
||||
project_id: context::id(&input.project_id, "project id")?,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(context::error)
|
||||
}
|
||||
}
|
||||
@@ -1,0 +1,121 @@
|
||||
use async_graphql::{Context, ID, Object};
|
||||
|
||||
use crate::context;
|
||||
use crate::types::{Checkout, Document, Environment, Issue, Project, Version, Workspace};
|
||||
|
||||
pub struct Query;
|
||||
|
||||
#[Object]
|
||||
impl Query {
|
||||
async fn version(&self) -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
async fn envelope_protocol_version(&self) -> u32 {
|
||||
syncode_collab_model::envelope::PROTOCOL_VERSION.0
|
||||
}
|
||||
|
||||
async fn workspaces(&self, ctx: &Context<'_>) -> async_graphql::Result<Vec<Workspace>> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.list_workspaces(principal)
|
||||
.await
|
||||
.map(|values| values.into_iter().map(Into::into).collect())
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn workspace(&self, ctx: &Context<'_>, id: ID) -> async_graphql::Result<Workspace> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.workspace(principal, context::id(&id, "workspace id")?)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn checkout(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
workspace_id: ID,
|
||||
environment: Option<String>,
|
||||
partial: Option<bool>,
|
||||
) -> async_graphql::Result<Checkout> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.checkout(
|
||||
principal,
|
||||
context::id(&workspace_id, "workspace id")?,
|
||||
environment.as_deref(),
|
||||
partial.unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn projects(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
workspace_id: ID,
|
||||
) -> async_graphql::Result<Vec<Project>> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.list_projects(principal, context::id(&workspace_id, "workspace id")?)
|
||||
.await
|
||||
.map(|values| values.into_iter().map(Into::into).collect())
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn environments(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
workspace_id: ID,
|
||||
) -> async_graphql::Result<Vec<Environment>> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.list_environments(principal, context::id(&workspace_id, "workspace id")?)
|
||||
.await
|
||||
.map(|values| values.into_iter().map(Into::into).collect())
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn documents(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
workspace_id: ID,
|
||||
project_id: Option<ID>,
|
||||
) -> async_graphql::Result<Vec<Document>> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.list_documents(
|
||||
principal,
|
||||
context::id(&workspace_id, "workspace id")?,
|
||||
context::optional_id(project_id.as_ref(), "project id")?,
|
||||
)
|
||||
.await
|
||||
.map(|values| values.into_iter().map(Into::into).collect())
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn issues(&self, ctx: &Context<'_>, project_id: ID) -> async_graphql::Result<Vec<Issue>> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.list_issues(principal, context::id(&project_id, "project id")?)
|
||||
.await
|
||||
.map(|values| values.into_iter().map(Into::into).collect())
|
||||
.map_err(context::error)
|
||||
}
|
||||
|
||||
async fn versions(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
project_id: ID,
|
||||
) -> async_graphql::Result<Vec<Version>> {
|
||||
let principal = context::principal(ctx).await?;
|
||||
context::app(ctx)?
|
||||
.list_versions(principal, context::id(&project_id, "project id")?)
|
||||
.await
|
||||
.map(|values| values.into_iter().map(Into::into).collect())
|
||||
.map_err(context::error)
|
||||
}
|
||||
}
|
||||
@@ -1,0 +1,226 @@
|
||||
use async_graphql::{Enum, ID, SimpleObject};
|
||||
use syncode_collab_model as model;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Enum)]
|
||||
pub enum OwnerKind {
|
||||
User,
|
||||
Organization,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Enum)]
|
||||
pub enum RepositoryRole {
|
||||
Source,
|
||||
Infra,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Enum)]
|
||||
pub enum ProjectKind {
|
||||
Continuous,
|
||||
Finite,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Enum)]
|
||||
pub enum IssuePriority {
|
||||
None,
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Urgent,
|
||||
}
|
||||
|
||||
#[derive(Clone, SimpleObject)]
|
||||
pub struct Workspace {
|
||||
pub id: ID,
|
||||
pub owner_kind: OwnerKind,
|
||||
pub owner_id: ID,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub default_environment: String,
|
||||
pub version: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, SimpleObject)]
|
||||
pub struct CheckoutRepository {
|
||||
pub repository_id: ID,
|
||||
pub owner: String,
|
||||
pub name: String,
|
||||
pub role: RepositoryRole,
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, SimpleObject)]
|
||||
pub struct Checkout {
|
||||
pub workspace: Workspace,
|
||||
pub environment: String,
|
||||
pub repositories: Vec<CheckoutRepository>,
|
||||
pub omitted_repositories: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, SimpleObject)]
|
||||
pub struct Environment {
|
||||
pub name: String,
|
||||
pub default_source_branch: String,
|
||||
pub infra_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, SimpleObject)]
|
||||
pub struct Project {
|
||||
pub id: ID,
|
||||
pub workspace_id: ID,
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: String,
|
||||
pub kind: ProjectKind,
|
||||
pub lifecycle: String,
|
||||
pub version: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, SimpleObject)]
|
||||
pub struct Document {
|
||||
pub id: ID,
|
||||
pub workspace_id: ID,
|
||||
pub project_id: Option<ID>,
|
||||
pub path: String,
|
||||
pub revision_id: ID,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, SimpleObject)]
|
||||
pub struct Issue {
|
||||
pub id: ID,
|
||||
pub project_id: ID,
|
||||
pub number: i64,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status_id: ID,
|
||||
pub type_id: ID,
|
||||
pub priority: IssuePriority,
|
||||
pub version: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, SimpleObject)]
|
||||
pub struct Version {
|
||||
pub id: ID,
|
||||
pub project_id: ID,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
impl From<model::Workspace> for Workspace {
|
||||
fn from(value: model::Workspace) -> Self {
|
||||
Self {
|
||||
id: value.id.into(),
|
||||
owner_kind: match value.owner_kind {
|
||||
model::OwnerKind::User => OwnerKind::User,
|
||||
model::OwnerKind::Organization => OwnerKind::Organization,
|
||||
},
|
||||
owner_id: value.owner_id.into(),
|
||||
name: value.name,
|
||||
slug: value.slug,
|
||||
default_environment: value.default_environment,
|
||||
version: value.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<model::Checkout> for Checkout {
|
||||
fn from(value: model::Checkout) -> Self {
|
||||
Self {
|
||||
workspace: value.workspace.into(),
|
||||
environment: value.environment,
|
||||
repositories: value
|
||||
.repositories
|
||||
.into_iter()
|
||||
.map(|repository| CheckoutRepository {
|
||||
repository_id: repository.repository_id.into(),
|
||||
owner: repository.owner,
|
||||
name: repository.name,
|
||||
role: match repository.role {
|
||||
model::RepositoryRole::Source => RepositoryRole::Source,
|
||||
model::RepositoryRole::Infra => RepositoryRole::Infra,
|
||||
},
|
||||
branch: repository.branch,
|
||||
})
|
||||
.collect(),
|
||||
omitted_repositories: value.omitted_repositories,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<model::Environment> for Environment {
|
||||
fn from(value: model::Environment) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
default_source_branch: value.default_source_branch,
|
||||
infra_branch: value.infra_branch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<model::Project> for Project {
|
||||
fn from(value: model::Project) -> Self {
|
||||
Self {
|
||||
id: value.id.into(),
|
||||
workspace_id: value.workspace_id.into(),
|
||||
key: value.key,
|
||||
name: value.name,
|
||||
slug: value.slug,
|
||||
description: value.description,
|
||||
kind: match value.kind {
|
||||
model::ProjectKind::Continuous => ProjectKind::Continuous,
|
||||
model::ProjectKind::Finite => ProjectKind::Finite,
|
||||
},
|
||||
lifecycle: format!("{:?}", value.lifecycle).to_lowercase(),
|
||||
version: value.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(model::Document, model::DocumentRevision)> for Document {
|
||||
fn from(value: (model::Document, model::DocumentRevision)) -> Self {
|
||||
Self {
|
||||
id: value.0.id.into(),
|
||||
workspace_id: value.0.workspace_id.into(),
|
||||
project_id: value.0.project_id.map(Into::into),
|
||||
path: value.0.path,
|
||||
revision_id: value.1.id.into(),
|
||||
content: value.1.content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<model::Issue> for Issue {
|
||||
fn from(value: model::Issue) -> Self {
|
||||
Self {
|
||||
id: value.id.into(),
|
||||
project_id: value.project_id.into(),
|
||||
number: value.number,
|
||||
title: value.title,
|
||||
description: value.description,
|
||||
status_id: value.status_id.into(),
|
||||
type_id: value.type_id.into(),
|
||||
priority: match value.priority {
|
||||
model::IssuePriority::None => IssuePriority::None,
|
||||
model::IssuePriority::Low => IssuePriority::Low,
|
||||
model::IssuePriority::Normal => IssuePriority::Normal,
|
||||
model::IssuePriority::High => IssuePriority::High,
|
||||
model::IssuePriority::Urgent => IssuePriority::Urgent,
|
||||
},
|
||||
version: value.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<model::Version> for Version {
|
||||
fn from(value: model::Version) -> Self {
|
||||
Self {
|
||||
id: value.id.into(),
|
||||
project_id: value.project_id.into(),
|
||||
name: value.name,
|
||||
slug: value.slug,
|
||||
state: format!("{:?}", value.state).to_lowercase(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,0 +1,62 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package syncode.identity.v1;
|
||||
|
||||
service Identity {
|
||||
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
|
||||
rpc CheckCapability(CheckCapabilityRequest) returns (CheckCapabilityResponse);
|
||||
rpc ResolveRepository(ResolveRepositoryRequest) returns (ResolveRepositoryResponse);
|
||||
rpc GetRepositoryCoordinates(GetRepositoryCoordinatesRequest) returns (GetRepositoryCoordinatesResponse);
|
||||
}
|
||||
|
||||
enum PrincipalKind {
|
||||
PRINCIPAL_KIND_UNSPECIFIED = 0;
|
||||
PRINCIPAL_KIND_USER = 1;
|
||||
PRINCIPAL_KIND_PLATFORM_AGENT = 2;
|
||||
PRINCIPAL_KIND_LOCAL_AGENT = 3;
|
||||
PRINCIPAL_KIND_ACCESS_TOKEN = 4;
|
||||
}
|
||||
|
||||
enum ResourceKind {
|
||||
RESOURCE_KIND_UNSPECIFIED = 0;
|
||||
RESOURCE_KIND_ORGANIZATION = 1;
|
||||
RESOURCE_KIND_REPOSITORY = 2;
|
||||
RESOURCE_KIND_INSTANCE = 3;
|
||||
}
|
||||
|
||||
message ValidateSessionRequest { string session_token = 1; }
|
||||
|
||||
message ValidateSessionResponse {
|
||||
string principal_id = 1;
|
||||
PrincipalKind principal_kind = 2;
|
||||
int64 expires_at_unix = 3;
|
||||
string owner_user_id = 4;
|
||||
repeated string capabilities = 5;
|
||||
string audience = 6;
|
||||
ResourceKind resource_kind = 7;
|
||||
string resource_id = 8;
|
||||
}
|
||||
|
||||
message CheckCapabilityRequest {
|
||||
string principal_id = 1;
|
||||
PrincipalKind principal_kind = 2;
|
||||
ResourceKind resource_kind = 3;
|
||||
string resource_id = 4;
|
||||
string capability = 5;
|
||||
}
|
||||
|
||||
message CheckCapabilityResponse { bool allowed = 1; }
|
||||
|
||||
message ResolveRepositoryRequest {
|
||||
string owner = 1;
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
message ResolveRepositoryResponse { string repository_id = 1; }
|
||||
|
||||
message GetRepositoryCoordinatesRequest { string repository_id = 1; }
|
||||
|
||||
message GetRepositoryCoordinatesResponse {
|
||||
string owner = 1;
|
||||
string name = 2;
|
||||
}
|
||||
@@ -1,0 +1,127 @@
|
||||
use tonic::Status;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::proto;
|
||||
use syncode_collab_application::CollaborationError;
|
||||
use syncode_collab_application::ports::{AccessError, StoreError};
|
||||
use syncode_collab_model as model;
|
||||
|
||||
pub(crate) fn id(value: &str, field: &str) -> Result<Uuid, Status> {
|
||||
value
|
||||
.parse()
|
||||
.map_err(|_| Status::invalid_argument(format!("{field} must be a UUID")))
|
||||
}
|
||||
|
||||
pub(crate) fn optional_id(value: &str, field: &str) -> Result<Option<Uuid>, Status> {
|
||||
(!value.is_empty()).then(|| id(value, field)).transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn workspace(value: model::Workspace) -> proto::Workspace {
|
||||
proto::Workspace {
|
||||
id: value.id.to_string(),
|
||||
owner_kind: match value.owner_kind {
|
||||
model::OwnerKind::User => proto::OwnerKind::User.into(),
|
||||
model::OwnerKind::Organization => proto::OwnerKind::Organization.into(),
|
||||
},
|
||||
owner_id: value.owner_id.to_string(),
|
||||
name: value.name,
|
||||
slug: value.slug,
|
||||
default_environment: value.default_environment,
|
||||
version: value.version,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn environment(value: model::Environment) -> proto::Environment {
|
||||
proto::Environment {
|
||||
workspace_id: value.workspace_id.to_string(),
|
||||
name: value.name,
|
||||
default_source_branch: value.default_source_branch,
|
||||
infra_branch: value.infra_branch.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn project(value: model::Project) -> proto::Project {
|
||||
proto::Project {
|
||||
id: value.id.to_string(),
|
||||
workspace_id: value.workspace_id.to_string(),
|
||||
key: value.key,
|
||||
name: value.name,
|
||||
slug: value.slug,
|
||||
description: value.description,
|
||||
kind: match value.kind {
|
||||
model::ProjectKind::Continuous => proto::ProjectKind::Continuous.into(),
|
||||
model::ProjectKind::Finite => proto::ProjectKind::Finite.into(),
|
||||
},
|
||||
lifecycle: format!("{:?}", value.lifecycle).to_lowercase(),
|
||||
default_status_id: value.default_status_id.to_string(),
|
||||
completion_status_id: value.completion_status_id.to_string(),
|
||||
version: value.version,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn document(value: (model::Document, model::DocumentRevision)) -> proto::Document {
|
||||
proto::Document {
|
||||
id: value.0.id.to_string(),
|
||||
workspace_id: value.0.workspace_id.to_string(),
|
||||
project_id: value
|
||||
.0
|
||||
.project_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_default(),
|
||||
path: value.0.path,
|
||||
revision_id: value.1.id.to_string(),
|
||||
content: value.1.content,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn issue(value: model::Issue) -> proto::Issue {
|
||||
proto::Issue {
|
||||
id: value.id.to_string(),
|
||||
project_id: value.project_id.to_string(),
|
||||
number: value.number,
|
||||
title: value.title,
|
||||
description: value.description,
|
||||
status_id: value.status_id.to_string(),
|
||||
type_id: value.type_id.to_string(),
|
||||
priority: match value.priority {
|
||||
model::IssuePriority::None => proto::IssuePriority::None.into(),
|
||||
model::IssuePriority::Low => proto::IssuePriority::Low.into(),
|
||||
model::IssuePriority::Normal => proto::IssuePriority::Normal.into(),
|
||||
model::IssuePriority::High => proto::IssuePriority::High.into(),
|
||||
model::IssuePriority::Urgent => proto::IssuePriority::Urgent.into(),
|
||||
},
|
||||
version: value.version,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn version(value: model::Version) -> proto::Version {
|
||||
proto::Version {
|
||||
id: value.id.to_string(),
|
||||
project_id: value.project_id.to_string(),
|
||||
name: value.name,
|
||||
slug: value.slug,
|
||||
state: format!("{:?}", value.state).to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn error(error: CollaborationError) -> Status {
|
||||
match error {
|
||||
CollaborationError::Access(AccessError::Unauthenticated) => {
|
||||
Status::unauthenticated(error.to_string())
|
||||
}
|
||||
CollaborationError::Access(AccessError::Denied) => {
|
||||
Status::permission_denied(error.to_string())
|
||||
}
|
||||
CollaborationError::Access(AccessError::NotFound)
|
||||
| CollaborationError::Store(StoreError::NotFound) => Status::not_found(error.to_string()),
|
||||
CollaborationError::Store(StoreError::Conflict(_)) => Status::aborted(error.to_string()),
|
||||
CollaborationError::Invalid(_) => Status::invalid_argument(error.to_string()),
|
||||
CollaborationError::Access(AccessError::Unavailable(_))
|
||||
| CollaborationError::Store(StoreError::Unavailable(_)) => {
|
||||
Status::unavailable(error.to_string())
|
||||
}
|
||||
CollaborationError::Store(StoreError::Rejected(_)) => {
|
||||
Status::failed_precondition(error.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,0 +1,192 @@
|
||||
use tonic::Status;
|
||||
|
||||
use crate::mapping;
|
||||
use crate::proto::*;
|
||||
use syncode_collab_application::{
|
||||
Collaboration, CreateDocument, CreateIssue, CreateProject, CreateVersion,
|
||||
};
|
||||
use syncode_collab_model as model;
|
||||
|
||||
pub(crate) async fn create_project(
|
||||
app: &Collaboration,
|
||||
request: CreateProjectRequest,
|
||||
) -> Result<Project, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let kind = match ProjectKind::try_from(request.kind) {
|
||||
Ok(ProjectKind::Continuous) => model::ProjectKind::Continuous,
|
||||
Ok(ProjectKind::Finite) => model::ProjectKind::Finite,
|
||||
Ok(ProjectKind::Unspecified) | Err(_) => {
|
||||
return Err(Status::invalid_argument("kind is required"));
|
||||
}
|
||||
};
|
||||
app.create_project(
|
||||
principal,
|
||||
CreateProject {
|
||||
workspace_id: mapping::id(&request.workspace_id, "workspace_id")?,
|
||||
key: request.key,
|
||||
name: request.name,
|
||||
slug: request.slug,
|
||||
description: request.description,
|
||||
kind,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(mapping::project)
|
||||
.map_err(mapping::error)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_projects(
|
||||
app: &Collaboration,
|
||||
request: ListProjectsRequest,
|
||||
) -> Result<ListProjectsResponse, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let projects = app
|
||||
.list_projects(
|
||||
principal,
|
||||
mapping::id(&request.workspace_id, "workspace_id")?,
|
||||
)
|
||||
.await
|
||||
.map_err(mapping::error)?
|
||||
.into_iter()
|
||||
.map(mapping::project)
|
||||
.collect();
|
||||
Ok(ListProjectsResponse { projects })
|
||||
}
|
||||
|
||||
pub(crate) async fn put_document(
|
||||
app: &Collaboration,
|
||||
request: PutDocumentRequest,
|
||||
) -> Result<Document, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
app.write_document(
|
||||
principal,
|
||||
CreateDocument {
|
||||
workspace_id: mapping::id(&request.workspace_id, "workspace_id")?,
|
||||
project_id: mapping::optional_id(&request.project_id, "project_id")?,
|
||||
path: request.path,
|
||||
content: request.content,
|
||||
base_revision_id: mapping::optional_id(&request.base_revision_id, "base_revision_id")?,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(mapping::document)
|
||||
.map_err(mapping::error)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_documents(
|
||||
app: &Collaboration,
|
||||
request: ListDocumentsRequest,
|
||||
) -> Result<ListDocumentsResponse, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let documents = app
|
||||
.list_documents(
|
||||
principal,
|
||||
mapping::id(&request.workspace_id, "workspace_id")?,
|
||||
mapping::optional_id(&request.project_id, "project_id")?,
|
||||
)
|
||||
.await
|
||||
.map_err(mapping::error)?
|
||||
.into_iter()
|
||||
.map(mapping::document)
|
||||
.collect();
|
||||
Ok(ListDocumentsResponse { documents })
|
||||
}
|
||||
|
||||
pub(crate) async fn create_issue(
|
||||
app: &Collaboration,
|
||||
request: CreateIssueRequest,
|
||||
) -> Result<Issue, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let priority = match IssuePriority::try_from(request.priority) {
|
||||
Ok(IssuePriority::None | IssuePriority::Unspecified) => model::IssuePriority::None,
|
||||
Ok(IssuePriority::Low) => model::IssuePriority::Low,
|
||||
Ok(IssuePriority::Normal) => model::IssuePriority::Normal,
|
||||
Ok(IssuePriority::High) => model::IssuePriority::High,
|
||||
Ok(IssuePriority::Urgent) => model::IssuePriority::Urgent,
|
||||
Err(_) => return Err(Status::invalid_argument("priority is invalid")),
|
||||
};
|
||||
app.create_issue(
|
||||
principal,
|
||||
CreateIssue {
|
||||
project_id: mapping::id(&request.project_id, "project_id")?,
|
||||
title: request.title,
|
||||
description: request.description,
|
||||
priority,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(mapping::issue)
|
||||
.map_err(mapping::error)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_issues(
|
||||
app: &Collaboration,
|
||||
request: ListIssuesRequest,
|
||||
) -> Result<ListIssuesResponse, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let issues = app
|
||||
.list_issues(principal, mapping::id(&request.project_id, "project_id")?)
|
||||
.await
|
||||
.map_err(mapping::error)?
|
||||
.into_iter()
|
||||
.map(mapping::issue)
|
||||
.collect();
|
||||
Ok(ListIssuesResponse { issues })
|
||||
}
|
||||
|
||||
pub(crate) async fn create_version(
|
||||
app: &Collaboration,
|
||||
request: CreateVersionRequest,
|
||||
) -> Result<Version, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
app.create_version(
|
||||
principal,
|
||||
CreateVersion {
|
||||
project_id: mapping::id(&request.project_id, "project_id")?,
|
||||
name: request.name,
|
||||
slug: request.slug,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(mapping::version)
|
||||
.map_err(mapping::error)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_versions(
|
||||
app: &Collaboration,
|
||||
request: ListVersionsRequest,
|
||||
) -> Result<ListVersionsResponse, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let versions = app
|
||||
.list_versions(principal, mapping::id(&request.project_id, "project_id")?)
|
||||
.await
|
||||
.map_err(mapping::error)?
|
||||
.into_iter()
|
||||
.map(mapping::version)
|
||||
.collect();
|
||||
Ok(ListVersionsResponse { versions })
|
||||
}
|
||||
@@ -1,0 +1,11 @@
|
||||
mod content;
|
||||
mod workspaces;
|
||||
|
||||
pub(crate) use content::{
|
||||
create_issue, create_project, create_version, list_documents, list_issues, list_projects,
|
||||
list_versions, put_document,
|
||||
};
|
||||
pub(crate) use workspaces::{
|
||||
attach_repository, configure_environment, create_workspace, get_checkout, get_workspace,
|
||||
list_environments, list_workspaces,
|
||||
};
|
||||
@@ -1,0 +1,188 @@
|
||||
use tonic::Status;
|
||||
|
||||
use crate::mapping;
|
||||
use crate::proto::*;
|
||||
use syncode_collab_application::{Collaboration, ConfigureEnvironment, CreateWorkspace};
|
||||
use syncode_collab_model as model;
|
||||
|
||||
pub(crate) async fn create_workspace(
|
||||
app: &Collaboration,
|
||||
request: CreateWorkspaceRequest,
|
||||
) -> Result<Workspace, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let owner_kind = match OwnerKind::try_from(request.owner_kind) {
|
||||
Ok(OwnerKind::User) => model::OwnerKind::User,
|
||||
Ok(OwnerKind::Organization) => model::OwnerKind::Organization,
|
||||
Ok(OwnerKind::Unspecified) | Err(_) => {
|
||||
return Err(Status::invalid_argument("owner_kind is required"));
|
||||
}
|
||||
};
|
||||
let owner_id = if request.owner_id.is_empty() && owner_kind == model::OwnerKind::User {
|
||||
principal.id
|
||||
} else {
|
||||
mapping::id(&request.owner_id, "owner_id")?
|
||||
};
|
||||
let workspace = app
|
||||
.create_workspace(
|
||||
principal,
|
||||
CreateWorkspace {
|
||||
owner_kind,
|
||||
owner_id,
|
||||
name: request.name,
|
||||
slug: request.slug,
|
||||
default_environment: request.default_environment,
|
||||
default_source_branch: request.default_source_branch,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
Ok(mapping::workspace(workspace))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_workspaces(
|
||||
app: &Collaboration,
|
||||
request: ListWorkspacesRequest,
|
||||
) -> Result<ListWorkspacesResponse, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let workspaces = app
|
||||
.list_workspaces(principal)
|
||||
.await
|
||||
.map_err(mapping::error)?
|
||||
.into_iter()
|
||||
.map(mapping::workspace)
|
||||
.collect();
|
||||
Ok(ListWorkspacesResponse { workspaces })
|
||||
}
|
||||
|
||||
pub(crate) async fn get_workspace(
|
||||
app: &Collaboration,
|
||||
request: GetWorkspaceRequest,
|
||||
) -> Result<Workspace, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
app.workspace(
|
||||
principal,
|
||||
mapping::id(&request.workspace_id, "workspace_id")?,
|
||||
)
|
||||
.await
|
||||
.map(mapping::workspace)
|
||||
.map_err(mapping::error)
|
||||
}
|
||||
|
||||
pub(crate) async fn attach_repository(
|
||||
app: &Collaboration,
|
||||
request: AttachRepositoryRequest,
|
||||
) -> Result<OperationResponse, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let role = match RepositoryRole::try_from(request.role) {
|
||||
Ok(RepositoryRole::Source) => model::RepositoryRole::Source,
|
||||
Ok(RepositoryRole::Infra) => model::RepositoryRole::Infra,
|
||||
Ok(RepositoryRole::Unspecified) | Err(_) => {
|
||||
return Err(Status::invalid_argument("role is required"));
|
||||
}
|
||||
};
|
||||
app.attach_repository(
|
||||
principal,
|
||||
mapping::id(&request.workspace_id, "workspace_id")?,
|
||||
&request.owner,
|
||||
&request.name,
|
||||
role,
|
||||
&request.branch,
|
||||
)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
Ok(OperationResponse {})
|
||||
}
|
||||
|
||||
pub(crate) async fn get_checkout(
|
||||
app: &Collaboration,
|
||||
request: GetCheckoutRequest,
|
||||
) -> Result<Checkout, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let environment = (!request.environment.is_empty()).then_some(request.environment.as_str());
|
||||
let value = app
|
||||
.checkout(
|
||||
principal,
|
||||
mapping::id(&request.workspace_id, "workspace_id")?,
|
||||
environment,
|
||||
request.partial,
|
||||
)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
Ok(Checkout {
|
||||
workspace: Some(mapping::workspace(value.workspace)),
|
||||
environment: value.environment,
|
||||
repositories: value
|
||||
.repositories
|
||||
.into_iter()
|
||||
.map(|repository| CheckoutRepository {
|
||||
repository_id: repository.repository_id.to_string(),
|
||||
owner: repository.owner,
|
||||
name: repository.name,
|
||||
role: match repository.role {
|
||||
model::RepositoryRole::Source => RepositoryRole::Source.into(),
|
||||
model::RepositoryRole::Infra => RepositoryRole::Infra.into(),
|
||||
},
|
||||
branch: repository.branch,
|
||||
})
|
||||
.collect(),
|
||||
omitted_repositories: value.omitted_repositories,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn configure_environment(
|
||||
app: &Collaboration,
|
||||
request: ConfigureEnvironmentRequest,
|
||||
) -> Result<Environment, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
app.configure_environment(
|
||||
principal,
|
||||
ConfigureEnvironment {
|
||||
workspace_id: mapping::id(&request.workspace_id, "workspace_id")?,
|
||||
name: request.name,
|
||||
default_source_branch: request.default_source_branch,
|
||||
infra_branch: (!request.infra_branch.is_empty()).then_some(request.infra_branch),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(mapping::environment)
|
||||
.map_err(mapping::error)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_environments(
|
||||
app: &Collaboration,
|
||||
request: ListEnvironmentsRequest,
|
||||
) -> Result<ListEnvironmentsResponse, Status> {
|
||||
let principal = app
|
||||
.authenticate(&request.session_token)
|
||||
.await
|
||||
.map_err(mapping::error)?;
|
||||
let environments = app
|
||||
.list_environments(
|
||||
principal,
|
||||
mapping::id(&request.workspace_id, "workspace_id")?,
|
||||
)
|
||||
.await
|
||||
.map_err(mapping::error)?
|
||||
.into_iter()
|
||||
.map(mapping::environment)
|
||||
.collect();
|
||||
Ok(ListEnvironmentsResponse { environments })
|
||||
}
|
||||
@@ -1,0 +1,82 @@
|
||||
use syncode_collab_model::{Environment, RepositoryRole};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::CollaborationError;
|
||||
|
||||
pub(crate) fn branch_for(
|
||||
environment: &Environment,
|
||||
repository_id: Uuid,
|
||||
role: RepositoryRole,
|
||||
) -> Result<String, CollaborationError> {
|
||||
if let Some((_, branch)) = environment
|
||||
.branch_overrides
|
||||
.iter()
|
||||
.find(|(id, _)| *id == repository_id)
|
||||
{
|
||||
return Ok(branch.clone());
|
||||
}
|
||||
match role {
|
||||
RepositoryRole::Source => Ok(environment.default_source_branch.clone()),
|
||||
RepositoryRole::Infra => environment.infra_branch.clone().ok_or_else(|| {
|
||||
CollaborationError::Invalid("infra branch is not configured".to_owned())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_name(value: &str, field: &str) -> Result<(), CollaborationError> {
|
||||
if value.trim().is_empty() || value.len() > 200 {
|
||||
return Err(CollaborationError::Invalid(format!(
|
||||
"{field} must contain 1 to 200 characters"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_slug(value: &str) -> Result<(), CollaborationError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 80
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
{
|
||||
return Err(CollaborationError::Invalid(
|
||||
"slug must contain only lowercase letters, digits, and hyphens".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_project_key(value: &str) -> Result<(), CollaborationError> {
|
||||
if value.len() < 2
|
||||
|| value.len() > 10
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
|
||||
{
|
||||
return Err(CollaborationError::Invalid(
|
||||
"project key must contain 2 to 10 uppercase letters or digits".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_branch(value: &str) -> Result<(), CollaborationError> {
|
||||
validate_name(value, "branch")?;
|
||||
if value.starts_with('-') || value.contains("..") || value.contains([' ', '~', '^', ':']) {
|
||||
return Err(CollaborationError::Invalid("branch is invalid".to_owned()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_document_path(value: &str) -> Result<(), CollaborationError> {
|
||||
if value.is_empty()
|
||||
|| value.starts_with('/')
|
||||
|| value.split('/').any(|part| part.is_empty() || part == "..")
|
||||
|| !value.ends_with(".md")
|
||||
{
|
||||
return Err(CollaborationError::Invalid(
|
||||
"document path must be a relative Markdown path".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,0 +1,87 @@
|
||||
use syncode_collab_model::{IssuePriority, OwnerKind, ProjectKind};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ports::{AccessError, StoreError};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PrincipalKind {
|
||||
User,
|
||||
PlatformAgent,
|
||||
LocalAgent,
|
||||
AccessToken,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Principal {
|
||||
pub id: Uuid,
|
||||
pub kind: PrincipalKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RepositoryCoordinates {
|
||||
pub id: Uuid,
|
||||
pub owner: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateWorkspace {
|
||||
pub owner_kind: OwnerKind,
|
||||
pub owner_id: Uuid,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub default_environment: String,
|
||||
pub default_source_branch: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateProject {
|
||||
pub workspace_id: Uuid,
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: String,
|
||||
pub kind: ProjectKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateDocument {
|
||||
pub workspace_id: Uuid,
|
||||
pub project_id: Option<Uuid>,
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub base_revision_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateIssue {
|
||||
pub project_id: Uuid,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub priority: IssuePriority,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateVersion {
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConfigureEnvironment {
|
||||
pub workspace_id: Uuid,
|
||||
pub name: String,
|
||||
pub default_source_branch: String,
|
||||
pub infra_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CollaborationError {
|
||||
#[error(transparent)]
|
||||
Access(#[from] AccessError),
|
||||
#[error(transparent)]
|
||||
Store(#[from] StoreError),
|
||||
#[error("invalid collaboration input: {0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
@@ -1,0 +1,25 @@
|
||||
use syncode_collab_model::Version;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::validation::{validate_name, validate_slug};
|
||||
use crate::{Collaboration, CollaborationError, CreateVersion, Principal};
|
||||
|
||||
impl Collaboration {
|
||||
pub async fn create_version(
|
||||
&self,
|
||||
principal: Principal,
|
||||
command: CreateVersion,
|
||||
) -> Result<Version, CollaborationError> {
|
||||
validate_name(&command.name, "version name")?;
|
||||
validate_slug(&command.slug)?;
|
||||
Ok(self.store.create_version(principal.id, &command).await?)
|
||||
}
|
||||
|
||||
pub async fn list_versions(
|
||||
&self,
|
||||
principal: Principal,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Version>, CollaborationError> {
|
||||
Ok(self.store.list_versions(principal.id, project_id).await?)
|
||||
}
|
||||
}
|
||||
@@ -1,0 +1,239 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use syncode_collab_model::{
|
||||
Checkout, CheckoutRepository, Document, DocumentRevision, Issue, OwnerKind, Project,
|
||||
RepositoryRole, Workspace,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ports::{AccessError, CollaborationStore, IdentityAccess};
|
||||
use crate::validation::{
|
||||
branch_for, validate_branch, validate_document_path, validate_name, validate_project_key,
|
||||
validate_slug,
|
||||
};
|
||||
use crate::{
|
||||
CollaborationError, ConfigureEnvironment, CreateDocument, CreateIssue, CreateProject,
|
||||
CreateWorkspace, Principal,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Collaboration {
|
||||
pub(crate) store: Arc<dyn CollaborationStore>,
|
||||
identity: Arc<dyn IdentityAccess>,
|
||||
}
|
||||
|
||||
impl Collaboration {
|
||||
pub fn new(store: Arc<dyn CollaborationStore>, identity: Arc<dyn IdentityAccess>) -> Self {
|
||||
Self { store, identity }
|
||||
}
|
||||
|
||||
pub async fn authenticate(&self, token: &str) -> Result<Principal, CollaborationError> {
|
||||
if token.is_empty() {
|
||||
return Err(CollaborationError::Access(AccessError::Unauthenticated));
|
||||
}
|
||||
Ok(self.identity.authenticate(token).await?)
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
&self,
|
||||
principal: Principal,
|
||||
command: CreateWorkspace,
|
||||
) -> Result<Workspace, CollaborationError> {
|
||||
validate_name(&command.name, "workspace name")?;
|
||||
validate_slug(&command.slug)?;
|
||||
validate_name(&command.default_environment, "environment name")?;
|
||||
validate_branch(&command.default_source_branch)?;
|
||||
match command.owner_kind {
|
||||
OwnerKind::User if command.owner_id != principal.id => {
|
||||
return Err(CollaborationError::Access(AccessError::Denied));
|
||||
}
|
||||
OwnerKind::Organization => {
|
||||
self.identity
|
||||
.require_organization_admin(principal, command.owner_id)
|
||||
.await?;
|
||||
}
|
||||
OwnerKind::User => {}
|
||||
}
|
||||
Ok(self.store.create_workspace(principal.id, &command).await?)
|
||||
}
|
||||
|
||||
pub async fn list_workspaces(
|
||||
&self,
|
||||
principal: Principal,
|
||||
) -> Result<Vec<Workspace>, CollaborationError> {
|
||||
Ok(self.store.list_workspaces(principal.id).await?)
|
||||
}
|
||||
|
||||
pub async fn workspace(
|
||||
&self,
|
||||
principal: Principal,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Workspace, CollaborationError> {
|
||||
Ok(self.store.workspace(principal.id, workspace_id).await?)
|
||||
}
|
||||
|
||||
pub async fn attach_repository(
|
||||
&self,
|
||||
principal: Principal,
|
||||
workspace_id: Uuid,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
role: RepositoryRole,
|
||||
branch: &str,
|
||||
) -> Result<(), CollaborationError> {
|
||||
validate_branch(branch)?;
|
||||
self.store.workspace(principal.id, workspace_id).await?;
|
||||
let repository = self
|
||||
.identity
|
||||
.resolve_readable_repository(principal, owner, name)
|
||||
.await?;
|
||||
for stored in self.store.repositories(principal.id, workspace_id).await? {
|
||||
let existing = self
|
||||
.identity
|
||||
.repository_coordinates(principal, stored.repository_id)
|
||||
.await?;
|
||||
if role == RepositoryRole::Source
|
||||
&& stored.role == RepositoryRole::Source
|
||||
&& existing.name == repository.name
|
||||
&& existing.id != repository.id
|
||||
{
|
||||
return Err(CollaborationError::Invalid(format!(
|
||||
"workspace already contains a source path named {}",
|
||||
repository.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
self.store
|
||||
.attach_repository(principal.id, workspace_id, repository.id, role, branch)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn checkout(
|
||||
&self,
|
||||
principal: Principal,
|
||||
workspace_id: Uuid,
|
||||
environment: Option<&str>,
|
||||
partial: bool,
|
||||
) -> Result<Checkout, CollaborationError> {
|
||||
let workspace = self.store.workspace(principal.id, workspace_id).await?;
|
||||
let environment_name = environment.unwrap_or(&workspace.default_environment);
|
||||
let environment = self
|
||||
.store
|
||||
.environment(principal.id, workspace_id, environment_name)
|
||||
.await?;
|
||||
let repositories = self.store.repositories(principal.id, workspace_id).await?;
|
||||
let mut checkout = Vec::new();
|
||||
let mut omitted = 0_u32;
|
||||
for stored in repositories {
|
||||
match self
|
||||
.identity
|
||||
.repository_coordinates(principal, stored.repository_id)
|
||||
.await
|
||||
{
|
||||
Ok(repository) => checkout.push(CheckoutRepository {
|
||||
repository_id: repository.id,
|
||||
owner: repository.owner,
|
||||
name: repository.name,
|
||||
role: stored.role,
|
||||
branch: branch_for(&environment, stored.repository_id, stored.role)?,
|
||||
}),
|
||||
Err(AccessError::Denied | AccessError::NotFound) if partial => omitted += 1,
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Ok(Checkout {
|
||||
workspace,
|
||||
environment: environment.name,
|
||||
repositories: checkout,
|
||||
omitted_repositories: omitted,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn configure_environment(
|
||||
&self,
|
||||
principal: Principal,
|
||||
command: ConfigureEnvironment,
|
||||
) -> Result<syncode_collab_model::Environment, CollaborationError> {
|
||||
validate_name(&command.name, "environment name")?;
|
||||
validate_branch(&command.default_source_branch)?;
|
||||
if let Some(branch) = &command.infra_branch {
|
||||
validate_branch(branch)?;
|
||||
}
|
||||
Ok(self
|
||||
.store
|
||||
.configure_environment(principal.id, &command)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_environments(
|
||||
&self,
|
||||
principal: Principal,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<syncode_collab_model::Environment>, CollaborationError> {
|
||||
Ok(self
|
||||
.store
|
||||
.list_environments(principal.id, workspace_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn create_project(
|
||||
&self,
|
||||
principal: Principal,
|
||||
command: CreateProject,
|
||||
) -> Result<Project, CollaborationError> {
|
||||
self.store
|
||||
.workspace(principal.id, command.workspace_id)
|
||||
.await?;
|
||||
validate_project_key(&command.key)?;
|
||||
validate_name(&command.name, "project name")?;
|
||||
validate_slug(&command.slug)?;
|
||||
Ok(self.store.create_project(principal.id, &command).await?)
|
||||
}
|
||||
|
||||
pub async fn list_projects(
|
||||
&self,
|
||||
principal: Principal,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<Project>, CollaborationError> {
|
||||
Ok(self.store.list_projects(principal.id, workspace_id).await?)
|
||||
}
|
||||
|
||||
pub async fn write_document(
|
||||
&self,
|
||||
principal: Principal,
|
||||
command: CreateDocument,
|
||||
) -> Result<(Document, DocumentRevision), CollaborationError> {
|
||||
validate_document_path(&command.path)?;
|
||||
Ok(self.store.write_document(principal.id, &command).await?)
|
||||
}
|
||||
|
||||
pub async fn list_documents(
|
||||
&self,
|
||||
principal: Principal,
|
||||
workspace_id: Uuid,
|
||||
project_id: Option<Uuid>,
|
||||
) -> Result<Vec<(Document, DocumentRevision)>, CollaborationError> {
|
||||
Ok(self
|
||||
.store
|
||||
.list_documents(principal.id, workspace_id, project_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn create_issue(
|
||||
&self,
|
||||
principal: Principal,
|
||||
command: CreateIssue,
|
||||
) -> Result<Issue, CollaborationError> {
|
||||
validate_name(&command.title, "issue title")?;
|
||||
Ok(self.store.create_issue(principal.id, &command).await?)
|
||||
}
|
||||
|
||||
pub async fn list_issues(
|
||||
&self,
|
||||
principal: Principal,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Issue>, CollaborationError> {
|
||||
Ok(self.store.list_issues(principal.id, project_id).await?)
|
||||
}
|
||||
}
|
||||
@@ -1,0 +1,29 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DocumentScope {
|
||||
Workspace,
|
||||
Project,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Document {
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub project_id: Option<Uuid>,
|
||||
pub path: String,
|
||||
pub current_revision_id: Uuid,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct DocumentRevision {
|
||||
pub id: Uuid,
|
||||
pub document_id: Uuid,
|
||||
pub previous_revision_id: Option<Uuid>,
|
||||
pub author_principal_id: Uuid,
|
||||
pub content: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -1,0 +1,30 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IssuePriority {
|
||||
None,
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Urgent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Issue {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub number: i64,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub type_id: Uuid,
|
||||
pub status_id: Uuid,
|
||||
pub priority: IssuePriority,
|
||||
pub assignee_principal_id: Option<Uuid>,
|
||||
pub parent_issue_id: Option<Uuid>,
|
||||
pub author_principal_id: Uuid,
|
||||
pub version: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -1,0 +1,61 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProjectKind {
|
||||
Continuous,
|
||||
Finite,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProjectLifecycle {
|
||||
Planned,
|
||||
Active,
|
||||
Completed,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IssueStatusCategory {
|
||||
Backlog,
|
||||
Unstarted,
|
||||
Started,
|
||||
Completed,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Project {
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: String,
|
||||
pub kind: ProjectKind,
|
||||
pub lifecycle: ProjectLifecycle,
|
||||
pub default_status_id: Uuid,
|
||||
pub completion_status_id: Uuid,
|
||||
pub version: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct IssueStatus {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub category: IssueStatusCategory,
|
||||
pub position: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct IssueType {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
@@ -1,0 +1,20 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VersionState {
|
||||
Planned,
|
||||
Released,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Version {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub state: VersionState,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub released_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -1,0 +1,64 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OwnerKind {
|
||||
User,
|
||||
Organization,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositoryRole {
|
||||
Source,
|
||||
Infra,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Workspace {
|
||||
pub id: Uuid,
|
||||
pub owner_kind: OwnerKind,
|
||||
pub owner_id: Uuid,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub default_environment: String,
|
||||
pub version: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct WorkspaceRepository {
|
||||
pub repository_id: Uuid,
|
||||
pub owner: String,
|
||||
pub name: String,
|
||||
pub role: RepositoryRole,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Environment {
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub name: String,
|
||||
pub default_source_branch: String,
|
||||
pub infra_branch: Option<String>,
|
||||
pub branch_overrides: Vec<(Uuid, String)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct CheckoutRepository {
|
||||
pub repository_id: Uuid,
|
||||
pub owner: String,
|
||||
pub name: String,
|
||||
pub role: RepositoryRole,
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Checkout {
|
||||
pub workspace: Workspace,
|
||||
pub environment: String,
|
||||
pub repositories: Vec<CheckoutRepository>,
|
||||
pub omitted_repositories: u32,
|
||||
}
|
||||
@@ -1,0 +1,144 @@
|
||||
CREATE TABLE workspace (
|
||||
id uuid PRIMARY KEY,
|
||||
owner_kind text NOT NULL CHECK (owner_kind IN ('user', 'organization')),
|
||||
owner_id uuid NOT NULL,
|
||||
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 200),
|
||||
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,79}$'),
|
||||
default_environment text NOT NULL,
|
||||
version bigint NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (owner_kind, owner_id, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE workspace_access (
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
principal_id uuid NOT NULL,
|
||||
capabilities text[] NOT NULL,
|
||||
PRIMARY KEY (workspace_id, principal_id)
|
||||
);
|
||||
|
||||
CREATE TABLE workspace_repository (
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
repository_id uuid NOT NULL,
|
||||
role text NOT NULL CHECK (role IN ('source', 'infra')),
|
||||
PRIMARY KEY (workspace_id, repository_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX workspace_one_infra
|
||||
ON workspace_repository (workspace_id) WHERE role = 'infra';
|
||||
|
||||
CREATE TABLE workspace_environment (
|
||||
id uuid PRIMARY KEY,
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
default_source_branch text NOT NULL,
|
||||
infra_branch text,
|
||||
UNIQUE (workspace_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE environment_branch_override (
|
||||
environment_id uuid NOT NULL REFERENCES workspace_environment(id) ON DELETE CASCADE,
|
||||
repository_id uuid NOT NULL,
|
||||
branch text NOT NULL,
|
||||
PRIMARY KEY (environment_id, repository_id)
|
||||
);
|
||||
|
||||
CREATE TABLE project (
|
||||
id uuid PRIMARY KEY,
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
key text NOT NULL CHECK (key ~ '^[A-Z0-9]{2,10}$'),
|
||||
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 200),
|
||||
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,79}$'),
|
||||
description text NOT NULL DEFAULT '',
|
||||
kind text NOT NULL CHECK (kind IN ('continuous', 'finite')),
|
||||
lifecycle text NOT NULL DEFAULT 'active'
|
||||
CHECK (lifecycle IN ('planned', 'active', 'completed', 'canceled')),
|
||||
default_status_id uuid NOT NULL,
|
||||
completion_status_id uuid NOT NULL,
|
||||
next_issue_number bigint NOT NULL DEFAULT 1,
|
||||
version bigint NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, key),
|
||||
UNIQUE (workspace_id, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE issue_status (
|
||||
id uuid PRIMARY KEY,
|
||||
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
category text NOT NULL CHECK (
|
||||
category IN ('backlog', 'unstarted', 'started', 'completed', 'canceled')
|
||||
),
|
||||
position integer NOT NULL,
|
||||
UNIQUE (project_id, name),
|
||||
UNIQUE (project_id, position)
|
||||
);
|
||||
|
||||
CREATE TABLE issue_type (
|
||||
id uuid PRIMARY KEY,
|
||||
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
UNIQUE (project_id, name)
|
||||
);
|
||||
|
||||
ALTER TABLE project
|
||||
ADD CONSTRAINT project_default_status_fk
|
||||
FOREIGN KEY (default_status_id) REFERENCES issue_status(id) DEFERRABLE INITIALLY DEFERRED,
|
||||
ADD CONSTRAINT project_completion_status_fk
|
||||
FOREIGN KEY (completion_status_id) REFERENCES issue_status(id) DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE TABLE document (
|
||||
id uuid PRIMARY KEY,
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
project_id uuid REFERENCES project(id) ON DELETE CASCADE,
|
||||
path text NOT NULL,
|
||||
current_revision_id uuid NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX workspace_document_path
|
||||
ON document (workspace_id, path) WHERE project_id IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX project_document_path
|
||||
ON document (project_id, path) WHERE project_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE document_revision (
|
||||
id uuid PRIMARY KEY,
|
||||
document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE,
|
||||
previous_revision_id uuid REFERENCES document_revision(id),
|
||||
author_principal_id uuid NOT NULL,
|
||||
content text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE document
|
||||
ADD CONSTRAINT document_current_revision_fk
|
||||
FOREIGN KEY (current_revision_id) REFERENCES document_revision(id)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE TABLE issue (
|
||||
id uuid PRIMARY KEY,
|
||||
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
||||
number bigint NOT NULL,
|
||||
title text NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
|
||||
description text NOT NULL DEFAULT '',
|
||||
type_id uuid NOT NULL REFERENCES issue_type(id),
|
||||
status_id uuid NOT NULL REFERENCES issue_status(id),
|
||||
priority text NOT NULL DEFAULT 'none'
|
||||
CHECK (priority IN ('none', 'low', 'normal', 'high', 'urgent')),
|
||||
assignee_principal_id uuid,
|
||||
parent_issue_id uuid REFERENCES issue(id),
|
||||
author_principal_id uuid NOT NULL,
|
||||
version bigint NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (project_id, number),
|
||||
CHECK (parent_issue_id IS NULL OR parent_issue_id <> id)
|
||||
);
|
||||
|
||||
CREATE INDEX workspace_access_principal ON workspace_access (principal_id, workspace_id);
|
||||
CREATE INDEX project_workspace ON project (workspace_id);
|
||||
CREATE INDEX issue_project ON issue (project_id, number);
|
||||
CREATE INDEX document_workspace ON document (workspace_id, project_id, path);
|
||||
@@ -1,0 +1,13 @@
|
||||
CREATE TABLE project_version (
|
||||
id uuid PRIMARY KEY,
|
||||
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
||||
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 200),
|
||||
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,79}$'),
|
||||
state text NOT NULL DEFAULT 'planned' CHECK (state IN ('planned', 'released')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
released_at timestamptz,
|
||||
UNIQUE (project_id, slug),
|
||||
CHECK ((state = 'released') = (released_at IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX project_version_project ON project_version (project_id, created_at, id);
|
||||
@@ -1,0 +1,187 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use syncode_collab_application::StoreError;
|
||||
use syncode_collab_application::ports::{CollaborationStore, StoredRepository};
|
||||
use syncode_collab_application::{CreateDocument, CreateIssue, CreateProject, CreateWorkspace};
|
||||
use syncode_collab_model::{
|
||||
Document, DocumentRevision, Environment, Issue, Project, RepositoryRole, Version, Workspace,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct PostgresCollaboration {
|
||||
pub(crate) pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresCollaboration {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CollaborationStore for PostgresCollaboration {
|
||||
async fn create_workspace(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &CreateWorkspace,
|
||||
) -> Result<Workspace, StoreError> {
|
||||
crate::working_workspaces::create(&self.pool, principal_id, command).await
|
||||
}
|
||||
|
||||
async fn list_workspaces(&self, principal_id: Uuid) -> Result<Vec<Workspace>, StoreError> {
|
||||
crate::working_workspaces::list(&self.pool, principal_id).await
|
||||
}
|
||||
|
||||
async fn workspace(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Workspace, StoreError> {
|
||||
crate::working_workspaces::get(&self.pool, principal_id, workspace_id).await
|
||||
}
|
||||
|
||||
async fn attach_repository(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
role: RepositoryRole,
|
||||
branch: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
crate::working_workspaces::attach_repository(
|
||||
&self.pool,
|
||||
principal_id,
|
||||
workspace_id,
|
||||
repository_id,
|
||||
role,
|
||||
branch,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn repositories(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<StoredRepository>, StoreError> {
|
||||
crate::working_workspaces::repositories(&self.pool, principal_id, workspace_id).await
|
||||
}
|
||||
|
||||
async fn environment(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
name: &str,
|
||||
) -> Result<Environment, StoreError> {
|
||||
crate::working_environments::get(&self.pool, principal_id, workspace_id, name).await
|
||||
}
|
||||
|
||||
async fn configure_environment(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &syncode_collab_application::ConfigureEnvironment,
|
||||
) -> Result<Environment, StoreError> {
|
||||
crate::working_environments::configure(&self.pool, principal_id, command).await
|
||||
}
|
||||
|
||||
async fn list_environments(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<Environment>, StoreError> {
|
||||
crate::working_environments::list(&self.pool, principal_id, workspace_id).await
|
||||
}
|
||||
|
||||
async fn create_project(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &CreateProject,
|
||||
) -> Result<Project, StoreError> {
|
||||
crate::working_projects::create(&self.pool, principal_id, command).await
|
||||
}
|
||||
|
||||
async fn list_projects(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<Project>, StoreError> {
|
||||
crate::working_projects::list(&self.pool, principal_id, workspace_id).await
|
||||
}
|
||||
|
||||
async fn write_document(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &CreateDocument,
|
||||
) -> Result<(Document, DocumentRevision), StoreError> {
|
||||
crate::working_documents::write(&self.pool, principal_id, command).await
|
||||
}
|
||||
|
||||
async fn list_documents(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
project_id: Option<Uuid>,
|
||||
) -> Result<Vec<(Document, DocumentRevision)>, StoreError> {
|
||||
crate::working_documents::list(&self.pool, principal_id, workspace_id, project_id).await
|
||||
}
|
||||
|
||||
async fn create_issue(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &CreateIssue,
|
||||
) -> Result<Issue, StoreError> {
|
||||
crate::working_issues::create(&self.pool, principal_id, command).await
|
||||
}
|
||||
|
||||
async fn list_issues(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Issue>, StoreError> {
|
||||
crate::working_issues::list(&self.pool, principal_id, project_id).await
|
||||
}
|
||||
|
||||
async fn create_version(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
command: &syncode_collab_application::CreateVersion,
|
||||
) -> Result<Version, StoreError> {
|
||||
crate::working_versions::create(&self.pool, principal_id, command).await
|
||||
}
|
||||
|
||||
async fn list_versions(
|
||||
&self,
|
||||
principal_id: Uuid,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Version>, StoreError> {
|
||||
crate::working_versions::list(&self.pool, principal_id, project_id).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unavailable(error: sqlx::Error) -> StoreError {
|
||||
StoreError::Unavailable(error.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn rejected(error: sqlx::Error) -> StoreError {
|
||||
StoreError::Rejected(error.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn outbox(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
message_type: &str,
|
||||
resource_id: Uuid,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<(), StoreError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO event_outbox (message_id, message_type, resource_id, payload) \
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(message_type)
|
||||
.bind(resource_id)
|
||||
.bind(payload)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,0 +1,205 @@
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use syncode_collab_application::{CreateDocument, StoreError};
|
||||
use syncode_collab_model::{Document, DocumentRevision};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::working::{outbox, rejected, unavailable};
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct DocumentRow {
|
||||
id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
project_id: Option<Uuid>,
|
||||
path: String,
|
||||
current_revision_id: Uuid,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
revision_id: Uuid,
|
||||
previous_revision_id: Option<Uuid>,
|
||||
author_principal_id: Uuid,
|
||||
content: String,
|
||||
revision_created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl DocumentRow {
|
||||
fn split(self) -> (Document, DocumentRevision) {
|
||||
(
|
||||
Document {
|
||||
id: self.id,
|
||||
workspace_id: self.workspace_id,
|
||||
project_id: self.project_id,
|
||||
path: self.path,
|
||||
current_revision_id: self.current_revision_id,
|
||||
updated_at: self.updated_at,
|
||||
},
|
||||
DocumentRevision {
|
||||
id: self.revision_id,
|
||||
document_id: self.id,
|
||||
previous_revision_id: self.previous_revision_id,
|
||||
author_principal_id: self.author_principal_id,
|
||||
content: self.content,
|
||||
created_at: self.revision_created_at,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const COLUMNS: &str = "d.id, d.workspace_id, d.project_id, d.path, \
|
||||
d.current_revision_id, d.updated_at, r.id AS revision_id, \
|
||||
r.previous_revision_id, r.author_principal_id, r.content, \
|
||||
r.created_at AS revision_created_at";
|
||||
|
||||
pub async fn write(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
command: &CreateDocument,
|
||||
) -> Result<(Document, DocumentRevision), StoreError> {
|
||||
let mut transaction = pool.begin().await.map_err(unavailable)?;
|
||||
require_scope(
|
||||
&mut transaction,
|
||||
principal_id,
|
||||
command.workspace_id,
|
||||
command.project_id,
|
||||
)
|
||||
.await?;
|
||||
let existing: Option<(Uuid, Uuid)> = sqlx::query_as(
|
||||
"SELECT id, current_revision_id FROM document WHERE workspace_id = $1 \
|
||||
AND project_id IS NOT DISTINCT FROM $2 AND path = $3 FOR UPDATE",
|
||||
)
|
||||
.bind(command.workspace_id)
|
||||
.bind(command.project_id)
|
||||
.bind(&command.path)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await
|
||||
.map_err(unavailable)?;
|
||||
let (document_id, previous_revision_id) = match existing {
|
||||
Some((document_id, current)) => {
|
||||
if command.base_revision_id != Some(current) {
|
||||
return Err(StoreError::Conflict(
|
||||
"document base revision is stale".to_owned(),
|
||||
));
|
||||
}
|
||||
(document_id, Some(current))
|
||||
}
|
||||
None if command.base_revision_id.is_some() => {
|
||||
return Err(StoreError::Conflict(
|
||||
"new document cannot have a base revision".to_owned(),
|
||||
));
|
||||
}
|
||||
None => (Uuid::new_v4(), None),
|
||||
};
|
||||
let revision_id = Uuid::new_v4();
|
||||
if existing.is_none() {
|
||||
sqlx::query(
|
||||
"INSERT INTO document \
|
||||
(id, workspace_id, project_id, path, current_revision_id) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(document_id)
|
||||
.bind(command.workspace_id)
|
||||
.bind(command.project_id)
|
||||
.bind(&command.path)
|
||||
.bind(revision_id)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO document_revision \
|
||||
(id, document_id, previous_revision_id, author_principal_id, content) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(revision_id)
|
||||
.bind(document_id)
|
||||
.bind(previous_revision_id)
|
||||
.bind(principal_id)
|
||||
.bind(&command.content)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
if existing.is_some() {
|
||||
sqlx::query(
|
||||
"UPDATE document SET current_revision_id = $2, updated_at = now() WHERE id = $1",
|
||||
)
|
||||
.bind(document_id)
|
||||
.bind(revision_id)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
}
|
||||
outbox(
|
||||
&mut transaction,
|
||||
"collaboration.document.revised",
|
||||
document_id,
|
||||
serde_json::json!({"document_id": document_id, "revision_id": revision_id, "actor_id": principal_id}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(unavailable)?;
|
||||
get(pool, principal_id, document_id).await
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
project_id: Option<Uuid>,
|
||||
) -> Result<Vec<(Document, DocumentRevision)>, StoreError> {
|
||||
let query = format!(
|
||||
"SELECT {COLUMNS} FROM document d \
|
||||
JOIN document_revision r ON r.id = d.current_revision_id \
|
||||
JOIN workspace_access a ON a.workspace_id = d.workspace_id \
|
||||
WHERE d.workspace_id = $1 AND d.project_id IS NOT DISTINCT FROM $2 \
|
||||
AND a.principal_id = $3 AND 'read' = ANY(a.capabilities) ORDER BY d.path"
|
||||
);
|
||||
Ok(sqlx::query_as::<_, DocumentRow>(&query)
|
||||
.bind(workspace_id)
|
||||
.bind(project_id)
|
||||
.bind(principal_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(unavailable)?
|
||||
.into_iter()
|
||||
.map(DocumentRow::split)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
document_id: Uuid,
|
||||
) -> Result<(Document, DocumentRevision), StoreError> {
|
||||
let query = format!(
|
||||
"SELECT {COLUMNS} FROM document d \
|
||||
JOIN document_revision r ON r.id = d.current_revision_id \
|
||||
JOIN workspace_access a ON a.workspace_id = d.workspace_id \
|
||||
WHERE d.id = $1 AND a.principal_id = $2 AND 'read' = ANY(a.capabilities)"
|
||||
);
|
||||
sqlx::query_as::<_, DocumentRow>(&query)
|
||||
.bind(document_id)
|
||||
.bind(principal_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(unavailable)?
|
||||
.map(DocumentRow::split)
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn require_scope(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
project_id: Option<Uuid>,
|
||||
) -> Result<(), StoreError> {
|
||||
let allowed: Option<(bool,)> = sqlx::query_as(
|
||||
"SELECT true FROM workspace_access a WHERE a.workspace_id = $1 \
|
||||
AND a.principal_id = $2 AND 'write' = ANY(a.capabilities) \
|
||||
AND ($3::uuid IS NULL OR EXISTS (SELECT 1 FROM project p \
|
||||
WHERE p.id = $3 AND p.workspace_id = a.workspace_id))",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(principal_id)
|
||||
.bind(project_id)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(unavailable)?;
|
||||
allowed.map(|_| ()).ok_or(StoreError::NotFound)
|
||||
}
|
||||
@@ -1,0 +1,95 @@
|
||||
use sqlx::PgPool;
|
||||
use syncode_collab_application::{ConfigureEnvironment, StoreError};
|
||||
use syncode_collab_model::Environment;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::working::{outbox, rejected, unavailable};
|
||||
|
||||
pub async fn get(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
name: &str,
|
||||
) -> Result<Environment, StoreError> {
|
||||
crate::working_workspaces::get(pool, principal_id, workspace_id).await?;
|
||||
let row: Option<(Uuid, String, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, name, default_source_branch, infra_branch \
|
||||
FROM workspace_environment WHERE workspace_id = $1 AND name = $2",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(unavailable)?;
|
||||
let (id, name, default_source_branch, infra_branch) = row.ok_or(StoreError::NotFound)?;
|
||||
let branch_overrides = sqlx::query_as(
|
||||
"SELECT repository_id, branch FROM environment_branch_override \
|
||||
WHERE environment_id = $1 ORDER BY repository_id",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(unavailable)?;
|
||||
Ok(Environment {
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
default_source_branch,
|
||||
infra_branch,
|
||||
branch_overrides,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn configure(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
command: &ConfigureEnvironment,
|
||||
) -> Result<Environment, StoreError> {
|
||||
let mut transaction = pool.begin().await.map_err(unavailable)?;
|
||||
crate::working_workspaces::require_write(&mut transaction, principal_id, command.workspace_id)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_environment \
|
||||
(id, workspace_id, name, default_source_branch, infra_branch) \
|
||||
VALUES ($1,$2,$3,$4,$5) ON CONFLICT (workspace_id, name) DO UPDATE SET \
|
||||
default_source_branch = EXCLUDED.default_source_branch, \
|
||||
infra_branch = EXCLUDED.infra_branch",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(command.workspace_id)
|
||||
.bind(&command.name)
|
||||
.bind(&command.default_source_branch)
|
||||
.bind(&command.infra_branch)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
outbox(
|
||||
&mut transaction,
|
||||
"collaboration.workspace.environment_configured",
|
||||
command.workspace_id,
|
||||
serde_json::json!({"workspace_id": command.workspace_id, "environment": command.name, "actor_id": principal_id}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(unavailable)?;
|
||||
get(pool, principal_id, command.workspace_id, &command.name).await
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<Environment>, StoreError> {
|
||||
crate::working_workspaces::get(pool, principal_id, workspace_id).await?;
|
||||
let names: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT name FROM workspace_environment WHERE workspace_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(unavailable)?;
|
||||
let mut values = Vec::with_capacity(names.len());
|
||||
for (name,) in names {
|
||||
values.push(get(pool, principal_id, workspace_id, &name).await?);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
@@ -1,0 +1,143 @@
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use syncode_collab_application::{CreateIssue, StoreError};
|
||||
use syncode_collab_model::{Issue, IssuePriority};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::working::{outbox, rejected, unavailable};
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct IssueRow {
|
||||
id: Uuid,
|
||||
project_id: Uuid,
|
||||
number: i64,
|
||||
title: String,
|
||||
description: String,
|
||||
type_id: Uuid,
|
||||
status_id: Uuid,
|
||||
priority: String,
|
||||
assignee_principal_id: Option<Uuid>,
|
||||
parent_issue_id: Option<Uuid>,
|
||||
author_principal_id: Uuid,
|
||||
version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<IssueRow> for Issue {
|
||||
type Error = StoreError;
|
||||
|
||||
fn try_from(row: IssueRow) -> Result<Self, Self::Error> {
|
||||
let priority = match row.priority.as_str() {
|
||||
"none" => IssuePriority::None,
|
||||
"low" => IssuePriority::Low,
|
||||
"normal" => IssuePriority::Normal,
|
||||
"high" => IssuePriority::High,
|
||||
"urgent" => IssuePriority::Urgent,
|
||||
value => {
|
||||
return Err(StoreError::Rejected(format!(
|
||||
"unknown issue priority {value}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
project_id: row.project_id,
|
||||
number: row.number,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
type_id: row.type_id,
|
||||
status_id: row.status_id,
|
||||
priority,
|
||||
assignee_principal_id: row.assignee_principal_id,
|
||||
parent_issue_id: row.parent_issue_id,
|
||||
author_principal_id: row.author_principal_id,
|
||||
version: row.version,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const COLUMNS: &str = "i.id, i.project_id, i.number, i.title, i.description, \
|
||||
i.type_id, i.status_id, i.priority, i.assignee_principal_id, i.parent_issue_id, \
|
||||
i.author_principal_id, i.version, i.created_at, i.updated_at";
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
command: &CreateIssue,
|
||||
) -> Result<Issue, StoreError> {
|
||||
let mut transaction = pool.begin().await.map_err(unavailable)?;
|
||||
let project: Option<(i64, Uuid, Uuid)> = sqlx::query_as(
|
||||
"UPDATE project p SET next_issue_number = next_issue_number + 1, \
|
||||
version = version + 1, updated_at = now() FROM workspace_access a \
|
||||
WHERE p.id = $1 AND a.workspace_id = p.workspace_id AND a.principal_id = $2 \
|
||||
AND 'write' = ANY(a.capabilities) \
|
||||
RETURNING p.next_issue_number - 1, p.default_status_id, \
|
||||
(SELECT id FROM issue_type WHERE project_id = p.id ORDER BY name LIMIT 1)",
|
||||
)
|
||||
.bind(command.project_id)
|
||||
.bind(principal_id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await
|
||||
.map_err(unavailable)?;
|
||||
let (number, status_id, type_id) = project.ok_or(StoreError::NotFound)?;
|
||||
let priority = match command.priority {
|
||||
IssuePriority::None => "none",
|
||||
IssuePriority::Low => "low",
|
||||
IssuePriority::Normal => "normal",
|
||||
IssuePriority::High => "high",
|
||||
IssuePriority::Urgent => "urgent",
|
||||
};
|
||||
let issue_id = Uuid::new_v4();
|
||||
let row = sqlx::query_as::<_, IssueRow>(
|
||||
"INSERT INTO issue (id, project_id, number, title, description, type_id, \
|
||||
status_id, priority, author_principal_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
|
||||
RETURNING id, project_id, number, title, description, type_id, status_id, \
|
||||
priority, assignee_principal_id, parent_issue_id, author_principal_id, \
|
||||
version, created_at, updated_at",
|
||||
)
|
||||
.bind(issue_id)
|
||||
.bind(command.project_id)
|
||||
.bind(number)
|
||||
.bind(&command.title)
|
||||
.bind(&command.description)
|
||||
.bind(type_id)
|
||||
.bind(status_id)
|
||||
.bind(priority)
|
||||
.bind(principal_id)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
outbox(
|
||||
&mut transaction,
|
||||
"collaboration.issue.created",
|
||||
issue_id,
|
||||
serde_json::json!({"issue_id": issue_id, "project_id": command.project_id, "number": number, "actor_id": principal_id}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(unavailable)?;
|
||||
row.try_into()
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Issue>, StoreError> {
|
||||
let query = format!(
|
||||
"SELECT {COLUMNS} FROM issue i JOIN project p ON p.id = i.project_id \
|
||||
JOIN workspace_access a ON a.workspace_id = p.workspace_id \
|
||||
WHERE i.project_id = $1 AND a.principal_id = $2 \
|
||||
AND 'read' = ANY(a.capabilities) ORDER BY i.number"
|
||||
);
|
||||
sqlx::query_as::<_, IssueRow>(&query)
|
||||
.bind(project_id)
|
||||
.bind(principal_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(unavailable)?
|
||||
.into_iter()
|
||||
.map(TryInto::try_into)
|
||||
.collect()
|
||||
}
|
||||
@@ -1,0 +1,172 @@
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use syncode_collab_application::{CreateProject, StoreError};
|
||||
use syncode_collab_model::{Project, ProjectKind, ProjectLifecycle};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::working::{outbox, rejected, unavailable};
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct ProjectRow {
|
||||
id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
key: String,
|
||||
name: String,
|
||||
slug: String,
|
||||
description: String,
|
||||
kind: String,
|
||||
lifecycle: String,
|
||||
default_status_id: Uuid,
|
||||
completion_status_id: Uuid,
|
||||
version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<ProjectRow> for Project {
|
||||
type Error = StoreError;
|
||||
|
||||
fn try_from(row: ProjectRow) -> Result<Self, Self::Error> {
|
||||
let kind = match row.kind.as_str() {
|
||||
"continuous" => ProjectKind::Continuous,
|
||||
"finite" => ProjectKind::Finite,
|
||||
value => {
|
||||
return Err(StoreError::Rejected(format!(
|
||||
"unknown project kind {value}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let lifecycle = match row.lifecycle.as_str() {
|
||||
"planned" => ProjectLifecycle::Planned,
|
||||
"active" => ProjectLifecycle::Active,
|
||||
"completed" => ProjectLifecycle::Completed,
|
||||
"canceled" => ProjectLifecycle::Canceled,
|
||||
value => {
|
||||
return Err(StoreError::Rejected(format!(
|
||||
"unknown project lifecycle {value}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id,
|
||||
key: row.key,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
description: row.description,
|
||||
kind,
|
||||
lifecycle,
|
||||
default_status_id: row.default_status_id,
|
||||
completion_status_id: row.completion_status_id,
|
||||
version: row.version,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const COLUMNS: &str = "p.id, p.workspace_id, p.key, p.name, p.slug, p.description, \
|
||||
p.kind, p.lifecycle, p.default_status_id, p.completion_status_id, p.version, \
|
||||
p.created_at, p.updated_at";
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
command: &CreateProject,
|
||||
) -> Result<Project, StoreError> {
|
||||
let mut transaction = pool.begin().await.map_err(unavailable)?;
|
||||
let allowed: Option<(bool,)> = sqlx::query_as(
|
||||
"SELECT true FROM workspace_access WHERE workspace_id = $1 \
|
||||
AND principal_id = $2 AND 'write' = ANY(capabilities)",
|
||||
)
|
||||
.bind(command.workspace_id)
|
||||
.bind(principal_id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await
|
||||
.map_err(unavailable)?;
|
||||
if allowed.is_none() {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
let project_id = Uuid::new_v4();
|
||||
let statuses = [
|
||||
(Uuid::new_v4(), "Backlog", "backlog", 0),
|
||||
(Uuid::new_v4(), "Todo", "unstarted", 1),
|
||||
(Uuid::new_v4(), "In Progress", "started", 2),
|
||||
(Uuid::new_v4(), "Done", "completed", 3),
|
||||
(Uuid::new_v4(), "Canceled", "canceled", 4),
|
||||
];
|
||||
let default_status_id = statuses[0].0;
|
||||
let completion_status_id = statuses[3].0;
|
||||
let kind = match command.kind {
|
||||
ProjectKind::Continuous => "continuous",
|
||||
ProjectKind::Finite => "finite",
|
||||
};
|
||||
let row = sqlx::query_as::<_, ProjectRow>(
|
||||
"INSERT INTO project (id, workspace_id, key, name, slug, description, kind, \
|
||||
default_status_id, completion_status_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
|
||||
RETURNING id, workspace_id, key, name, slug, description, kind, lifecycle, \
|
||||
default_status_id, completion_status_id, version, created_at, updated_at",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(command.workspace_id)
|
||||
.bind(&command.key)
|
||||
.bind(&command.name)
|
||||
.bind(&command.slug)
|
||||
.bind(&command.description)
|
||||
.bind(kind)
|
||||
.bind(default_status_id)
|
||||
.bind(completion_status_id)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
for (id, name, category, position) in statuses {
|
||||
sqlx::query(
|
||||
"INSERT INTO issue_status (id, project_id, name, category, position) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(project_id)
|
||||
.bind(name)
|
||||
.bind(category)
|
||||
.bind(position)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
}
|
||||
sqlx::query("INSERT INTO issue_type (id, project_id, name) VALUES ($1, $2, 'Issue')")
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(project_id)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
outbox(
|
||||
&mut transaction,
|
||||
"collaboration.project.created",
|
||||
project_id,
|
||||
serde_json::json!({"project_id": project_id, "workspace_id": command.workspace_id, "actor_id": principal_id}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(unavailable)?;
|
||||
row.try_into()
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<Project>, StoreError> {
|
||||
let query = format!(
|
||||
"SELECT {COLUMNS} FROM project p \
|
||||
JOIN workspace_access a ON a.workspace_id = p.workspace_id \
|
||||
WHERE p.workspace_id = $1 AND a.principal_id = $2 \
|
||||
AND 'read' = ANY(a.capabilities) ORDER BY p.name, p.id"
|
||||
);
|
||||
sqlx::query_as::<_, ProjectRow>(&query)
|
||||
.bind(workspace_id)
|
||||
.bind(principal_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(unavailable)?
|
||||
.into_iter()
|
||||
.map(TryInto::try_into)
|
||||
.collect()
|
||||
}
|
||||
@@ -1,0 +1,43 @@
|
||||
use sqlx::FromRow;
|
||||
use syncode_collab_application::StoreError;
|
||||
use syncode_collab_model::{OwnerKind, Workspace};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(FromRow)]
|
||||
pub(crate) struct WorkspaceRow {
|
||||
id: Uuid,
|
||||
owner_kind: String,
|
||||
owner_id: Uuid,
|
||||
name: String,
|
||||
slug: String,
|
||||
default_environment: String,
|
||||
version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<WorkspaceRow> for Workspace {
|
||||
type Error = StoreError;
|
||||
|
||||
fn try_from(row: WorkspaceRow) -> Result<Self, Self::Error> {
|
||||
let owner_kind = match row.owner_kind.as_str() {
|
||||
"user" => OwnerKind::User,
|
||||
"organization" => OwnerKind::Organization,
|
||||
value => return Err(StoreError::Rejected(format!("unknown owner kind {value}"))),
|
||||
};
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
owner_kind,
|
||||
owner_id: row.owner_id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
default_environment: row.default_environment,
|
||||
version: row.version,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const WORKSPACE_COLUMNS: &str = "w.id, w.owner_kind, w.owner_id, w.name, w.slug, \
|
||||
w.default_environment, w.version, w.created_at, w.updated_at";
|
||||
@@ -1,0 +1,106 @@
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use syncode_collab_application::{CreateVersion, StoreError};
|
||||
use syncode_collab_model::{Version, VersionState};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::working::{outbox, rejected, unavailable};
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct VersionRow {
|
||||
id: Uuid,
|
||||
project_id: Uuid,
|
||||
name: String,
|
||||
slug: String,
|
||||
state: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
released_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
impl TryFrom<VersionRow> for Version {
|
||||
type Error = StoreError;
|
||||
|
||||
fn try_from(row: VersionRow) -> Result<Self, Self::Error> {
|
||||
let state = match row.state.as_str() {
|
||||
"planned" => VersionState::Planned,
|
||||
"released" => VersionState::Released,
|
||||
value => {
|
||||
return Err(StoreError::Rejected(format!(
|
||||
"unknown version state {value}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
project_id: row.project_id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
state,
|
||||
created_at: row.created_at,
|
||||
released_at: row.released_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const COLUMNS: &str = "v.id, v.project_id, v.name, v.slug, v.state, v.created_at, v.released_at";
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
command: &CreateVersion,
|
||||
) -> Result<Version, StoreError> {
|
||||
let mut transaction = pool.begin().await.map_err(unavailable)?;
|
||||
let allowed: Option<(bool,)> = sqlx::query_as(
|
||||
"SELECT true FROM project p JOIN workspace_access a ON a.workspace_id = p.workspace_id \
|
||||
WHERE p.id = $1 AND a.principal_id = $2 AND 'write' = ANY(a.capabilities)",
|
||||
)
|
||||
.bind(command.project_id)
|
||||
.bind(principal_id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await
|
||||
.map_err(unavailable)?;
|
||||
if allowed.is_none() {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
let id = Uuid::new_v4();
|
||||
let row = sqlx::query_as::<_, VersionRow>(
|
||||
"INSERT INTO project_version (id, project_id, name, slug) VALUES ($1,$2,$3,$4) \
|
||||
RETURNING id, project_id, name, slug, state, created_at, released_at",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(command.project_id)
|
||||
.bind(&command.name)
|
||||
.bind(&command.slug)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
outbox(
|
||||
&mut transaction,
|
||||
"collaboration.version.created",
|
||||
id,
|
||||
serde_json::json!({"version_id": id, "project_id": command.project_id, "actor_id": principal_id}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(unavailable)?;
|
||||
row.try_into()
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Version>, StoreError> {
|
||||
let query = format!(
|
||||
"SELECT {COLUMNS} FROM project_version v JOIN project p ON p.id = v.project_id \
|
||||
JOIN workspace_access a ON a.workspace_id = p.workspace_id WHERE v.project_id = $1 \
|
||||
AND a.principal_id = $2 AND 'read' = ANY(a.capabilities) ORDER BY v.created_at, v.id"
|
||||
);
|
||||
sqlx::query_as::<_, VersionRow>(&query)
|
||||
.bind(project_id)
|
||||
.bind(principal_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(unavailable)?
|
||||
.into_iter()
|
||||
.map(TryInto::try_into)
|
||||
.collect()
|
||||
}
|
||||
@@ -1,0 +1,202 @@
|
||||
use sqlx::PgPool;
|
||||
use syncode_collab_application::ports::StoredRepository;
|
||||
use syncode_collab_application::{CreateWorkspace, StoreError};
|
||||
use syncode_collab_model::{OwnerKind, RepositoryRole, Workspace};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::working::{outbox, rejected, unavailable};
|
||||
use crate::working_rows::{WORKSPACE_COLUMNS, WorkspaceRow};
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
command: &CreateWorkspace,
|
||||
) -> Result<Workspace, StoreError> {
|
||||
let mut transaction = pool.begin().await.map_err(unavailable)?;
|
||||
let workspace_id = Uuid::new_v4();
|
||||
let environment_id = Uuid::new_v4();
|
||||
let owner_kind = match command.owner_kind {
|
||||
OwnerKind::User => "user",
|
||||
OwnerKind::Organization => "organization",
|
||||
};
|
||||
let row = sqlx::query_as::<_, WorkspaceRow>(
|
||||
"INSERT INTO workspace \
|
||||
(id, owner_kind, owner_id, name, slug, default_environment) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
RETURNING id, owner_kind, owner_id, name, slug, default_environment, \
|
||||
version, created_at, updated_at",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(owner_kind)
|
||||
.bind(command.owner_id)
|
||||
.bind(&command.name)
|
||||
.bind(&command.slug)
|
||||
.bind(&command.default_environment)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_access (workspace_id, principal_id, capabilities) \
|
||||
VALUES ($1, $2, ARRAY['read', 'write', 'admin'])",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(principal_id)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_environment \
|
||||
(id, workspace_id, name, default_source_branch) VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(environment_id)
|
||||
.bind(workspace_id)
|
||||
.bind(&command.default_environment)
|
||||
.bind(&command.default_source_branch)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
outbox(
|
||||
&mut transaction,
|
||||
"collaboration.workspace.created",
|
||||
workspace_id,
|
||||
serde_json::json!({"workspace_id": workspace_id, "actor_id": principal_id}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(unavailable)?;
|
||||
row.try_into()
|
||||
}
|
||||
|
||||
pub async fn list(pool: &PgPool, principal_id: Uuid) -> Result<Vec<Workspace>, StoreError> {
|
||||
let query = format!(
|
||||
"SELECT {WORKSPACE_COLUMNS} FROM workspace w \
|
||||
JOIN workspace_access a ON a.workspace_id = w.id \
|
||||
WHERE a.principal_id = $1 AND 'read' = ANY(a.capabilities) ORDER BY w.name, w.id"
|
||||
);
|
||||
sqlx::query_as::<_, WorkspaceRow>(&query)
|
||||
.bind(principal_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(unavailable)?
|
||||
.into_iter()
|
||||
.map(TryInto::try_into)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Workspace, StoreError> {
|
||||
let query = format!(
|
||||
"SELECT {WORKSPACE_COLUMNS} FROM workspace w \
|
||||
JOIN workspace_access a ON a.workspace_id = w.id \
|
||||
WHERE w.id = $1 AND a.principal_id = $2 AND 'read' = ANY(a.capabilities)"
|
||||
);
|
||||
sqlx::query_as::<_, WorkspaceRow>(&query)
|
||||
.bind(workspace_id)
|
||||
.bind(principal_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(unavailable)?
|
||||
.ok_or(StoreError::NotFound)?
|
||||
.try_into()
|
||||
}
|
||||
|
||||
pub async fn attach_repository(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
role: RepositoryRole,
|
||||
branch: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
let mut transaction = pool.begin().await.map_err(unavailable)?;
|
||||
require_write(&mut transaction, principal_id, workspace_id).await?;
|
||||
let role = match role {
|
||||
RepositoryRole::Source => "source",
|
||||
RepositoryRole::Infra => "infra",
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_repository (workspace_id, repository_id, role) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(repository_id)
|
||||
.bind(role)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
if role == "infra" {
|
||||
sqlx::query("UPDATE workspace_environment SET infra_branch = $2 WHERE workspace_id = $1")
|
||||
.bind(workspace_id)
|
||||
.bind(branch)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"INSERT INTO environment_branch_override (environment_id, repository_id, branch) \
|
||||
SELECT id, $2, $3 FROM workspace_environment WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(repository_id)
|
||||
.bind(branch)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(rejected)?;
|
||||
}
|
||||
outbox(
|
||||
&mut transaction,
|
||||
"collaboration.workspace.repository_attached",
|
||||
workspace_id,
|
||||
serde_json::json!({"workspace_id": workspace_id, "repository_id": repository_id, "role": role}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(unavailable)
|
||||
}
|
||||
|
||||
pub async fn repositories(
|
||||
pool: &PgPool,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<StoredRepository>, StoreError> {
|
||||
get(pool, principal_id, workspace_id).await?;
|
||||
let rows: Vec<(Uuid, String)> = sqlx::query_as(
|
||||
"SELECT repository_id, role FROM workspace_repository \
|
||||
WHERE workspace_id = $1 ORDER BY role, repository_id",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(unavailable)?;
|
||||
rows.into_iter()
|
||||
.map(|(repository_id, role)| {
|
||||
let role = match role.as_str() {
|
||||
"source" => RepositoryRole::Source,
|
||||
"infra" => RepositoryRole::Infra,
|
||||
value => return Err(StoreError::Rejected(format!("unknown role {value}"))),
|
||||
};
|
||||
Ok(StoredRepository {
|
||||
repository_id,
|
||||
role,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn require_write(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<(), StoreError> {
|
||||
let allowed: Option<(bool,)> = sqlx::query_as(
|
||||
"SELECT true FROM workspace_access WHERE workspace_id = $1 \
|
||||
AND principal_id = $2 AND 'write' = ANY(capabilities)",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(principal_id)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(unavailable)?;
|
||||
allowed.map(|_| ()).ok_or(StoreError::NotFound)
|
||||
}
|
||||
@@ -1,0 +1,216 @@
|
||||
use syncode_collab_application::ports::CollaborationStore;
|
||||
use syncode_collab_application::{
|
||||
ConfigureEnvironment, CreateDocument, CreateIssue, CreateProject, CreateVersion,
|
||||
CreateWorkspace, StoreError,
|
||||
};
|
||||
use syncode_collab_model::{IssuePriority, OwnerKind, ProjectKind, RepositoryRole};
|
||||
use syncode_collab_store::{PostgresCollaboration, connect, migrate};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn store() -> Option<(PostgresCollaboration, sqlx::PgPool)> {
|
||||
let url = std::env::var("SYNCODE_COLLAB_TEST_DATABASE_URL").ok()?;
|
||||
let pool = connect(&url, 4).await.ok()?;
|
||||
migrate(&pool).await.ok()?;
|
||||
sqlx::query(
|
||||
"TRUNCATE issue, document_revision, document, project_version, issue_type, issue_status, project, \
|
||||
environment_branch_override, workspace_environment, workspace_repository, \
|
||||
workspace_access, workspace, event_outbox RESTART IDENTITY CASCADE",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.ok()?;
|
||||
Some((PostgresCollaboration::new(pool.clone()), pool))
|
||||
}
|
||||
|
||||
async fn workspace(
|
||||
store: &PostgresCollaboration,
|
||||
principal_id: Uuid,
|
||||
) -> Result<syncode_collab_model::Workspace, StoreError> {
|
||||
store
|
||||
.create_workspace(
|
||||
principal_id,
|
||||
&CreateWorkspace {
|
||||
owner_kind: OwnerKind::User,
|
||||
owner_id: principal_id,
|
||||
name: "SynCode".to_owned(),
|
||||
slug: "syncode".to_owned(),
|
||||
default_environment: "development".to_owned(),
|
||||
default_source_branch: "develop".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn project(
|
||||
store: &PostgresCollaboration,
|
||||
principal_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<syncode_collab_model::Project, StoreError> {
|
||||
store
|
||||
.create_project(
|
||||
principal_id,
|
||||
&CreateProject {
|
||||
workspace_id,
|
||||
key: "COL".to_owned(),
|
||||
name: "Collaboration plane".to_owned(),
|
||||
slug: "collaboration-plane".to_owned(),
|
||||
description: String::new(),
|
||||
kind: ProjectKind::Finite,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_a_working_workspace_and_enforces_its_invariants()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let Some((store, _pool)) = store().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let principal_id = Uuid::new_v4();
|
||||
let workspace = workspace(&store, principal_id).await?;
|
||||
let source_id = Uuid::new_v4();
|
||||
store
|
||||
.attach_repository(
|
||||
principal_id,
|
||||
workspace.id,
|
||||
source_id,
|
||||
RepositoryRole::Source,
|
||||
"feature/workspace",
|
||||
)
|
||||
.await?;
|
||||
store
|
||||
.attach_repository(
|
||||
principal_id,
|
||||
workspace.id,
|
||||
Uuid::new_v4(),
|
||||
RepositoryRole::Infra,
|
||||
"main",
|
||||
)
|
||||
.await?;
|
||||
assert!(matches!(
|
||||
store
|
||||
.attach_repository(
|
||||
principal_id,
|
||||
workspace.id,
|
||||
Uuid::new_v4(),
|
||||
RepositoryRole::Infra,
|
||||
"main",
|
||||
)
|
||||
.await,
|
||||
Err(StoreError::Rejected(_))
|
||||
));
|
||||
let environment = store
|
||||
.environment(principal_id, workspace.id, "development")
|
||||
.await?;
|
||||
assert_eq!(Some("main"), environment.infra_branch.as_deref());
|
||||
assert_eq!(
|
||||
Some(&(source_id, "feature/workspace".to_owned())),
|
||||
environment.branch_overrides.first()
|
||||
);
|
||||
let production = store
|
||||
.configure_environment(
|
||||
principal_id,
|
||||
&ConfigureEnvironment {
|
||||
workspace_id: workspace.id,
|
||||
name: "production".to_owned(),
|
||||
default_source_branch: "main".to_owned(),
|
||||
infra_branch: Some("main".to_owned()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!("production", production.name);
|
||||
assert_eq!(
|
||||
2,
|
||||
store
|
||||
.list_environments(principal_id, workspace.id)
|
||||
.await?
|
||||
.len()
|
||||
);
|
||||
assert!(store.list_workspaces(Uuid::new_v4()).await?.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_stale_documents_and_allocates_issue_numbers()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let Some((store, _pool)) = store().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let principal_id = Uuid::new_v4();
|
||||
let workspace = workspace(&store, principal_id).await?;
|
||||
let project = project(&store, principal_id, workspace.id).await?;
|
||||
store
|
||||
.create_version(
|
||||
principal_id,
|
||||
&CreateVersion {
|
||||
project_id: project.id,
|
||||
name: "0.6.0".to_owned(),
|
||||
slug: "v0-6-0".to_owned(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
1,
|
||||
store.list_versions(principal_id, project.id).await?.len()
|
||||
);
|
||||
let first = store
|
||||
.write_document(
|
||||
principal_id,
|
||||
&CreateDocument {
|
||||
workspace_id: workspace.id,
|
||||
project_id: Some(project.id),
|
||||
path: "README.md".to_owned(),
|
||||
content: "first".to_owned(),
|
||||
base_revision_id: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let second = store
|
||||
.write_document(
|
||||
principal_id,
|
||||
&CreateDocument {
|
||||
workspace_id: workspace.id,
|
||||
project_id: Some(project.id),
|
||||
path: "README.md".to_owned(),
|
||||
content: "second".to_owned(),
|
||||
base_revision_id: Some(first.1.id),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(matches!(
|
||||
store
|
||||
.write_document(
|
||||
principal_id,
|
||||
&CreateDocument {
|
||||
workspace_id: workspace.id,
|
||||
project_id: Some(project.id),
|
||||
path: "README.md".to_owned(),
|
||||
content: "stale".to_owned(),
|
||||
base_revision_id: Some(first.1.id),
|
||||
},
|
||||
)
|
||||
.await,
|
||||
Err(StoreError::Conflict(_))
|
||||
));
|
||||
assert_eq!("second", second.1.content);
|
||||
for title in ["First", "Second"] {
|
||||
store
|
||||
.create_issue(
|
||||
principal_id,
|
||||
&CreateIssue {
|
||||
project_id: project.id,
|
||||
title: title.to_owned(),
|
||||
description: String::new(),
|
||||
priority: IssuePriority::Normal,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let issues = store.list_issues(principal_id, project.id).await?;
|
||||
assert_eq!(
|
||||
vec![1, 2],
|
||||
issues.iter().map(|issue| issue.number).collect::<Vec<_>>()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
+182
@@ -1,0 +1,182 @@
|
||||
use syncode_collab_api_grpc::identity_proto::identity_client::IdentityClient;
|
||||
use syncode_collab_api_grpc::identity_proto::{
|
||||
CheckCapabilityRequest, GetRepositoryCoordinatesRequest, PrincipalKind as WirePrincipalKind,
|
||||
ResolveRepositoryRequest, ResourceKind, ValidateSessionRequest,
|
||||
};
|
||||
use syncode_collab_application::ports::{AccessError, IdentityAccess};
|
||||
use syncode_collab_application::{Principal, PrincipalKind, RepositoryCoordinates};
|
||||
use tonic::metadata::{Ascii, MetadataValue};
|
||||
use tonic::transport::Channel;
|
||||
use tonic::{Code, Request};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IdentityClientAccess {
|
||||
client: IdentityClient<Channel>,
|
||||
authorization: MetadataValue<Ascii>,
|
||||
}
|
||||
|
||||
impl IdentityClientAccess {
|
||||
pub async fn connect(endpoint: String, shared_secret: String) -> Result<Self, AccessError> {
|
||||
let authorization = format!("Bearer {shared_secret}")
|
||||
.parse()
|
||||
.map_err(|_| AccessError::Unavailable("identity secret is invalid".to_owned()))?;
|
||||
let client = IdentityClient::connect(endpoint)
|
||||
.await
|
||||
.map_err(|error| AccessError::Unavailable(error.to_string()))?;
|
||||
Ok(Self {
|
||||
client,
|
||||
authorization,
|
||||
})
|
||||
}
|
||||
|
||||
fn request<T>(&self, body: T) -> Request<T> {
|
||||
let mut request = Request::new(body);
|
||||
request
|
||||
.metadata_mut()
|
||||
.insert("authorization", self.authorization.clone());
|
||||
request
|
||||
}
|
||||
|
||||
async fn require(
|
||||
&self,
|
||||
principal: Principal,
|
||||
resource_kind: ResourceKind,
|
||||
resource_id: Uuid,
|
||||
capability: &str,
|
||||
) -> Result<(), AccessError> {
|
||||
let allowed = self
|
||||
.client
|
||||
.clone()
|
||||
.check_capability(self.request(CheckCapabilityRequest {
|
||||
principal_id: principal.id.to_string(),
|
||||
principal_kind: wire_principal_kind(principal.kind).into(),
|
||||
resource_kind: resource_kind.into(),
|
||||
resource_id: resource_id.to_string(),
|
||||
capability: capability.to_owned(),
|
||||
}))
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner()
|
||||
.allowed;
|
||||
allowed.then_some(()).ok_or(AccessError::Denied)
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl IdentityAccess for IdentityClientAccess {
|
||||
async fn authenticate(&self, session_token: &str) -> Result<Principal, AccessError> {
|
||||
let response = self
|
||||
.client
|
||||
.clone()
|
||||
.validate_session(self.request(ValidateSessionRequest {
|
||||
session_token: session_token.to_owned(),
|
||||
}))
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
let id = response.principal_id.parse().map_err(|_| {
|
||||
AccessError::Unavailable("identity returned an invalid principal".to_owned())
|
||||
})?;
|
||||
let kind = match WirePrincipalKind::try_from(response.principal_kind) {
|
||||
Ok(WirePrincipalKind::User) => PrincipalKind::User,
|
||||
Ok(WirePrincipalKind::PlatformAgent) => PrincipalKind::PlatformAgent,
|
||||
Ok(WirePrincipalKind::LocalAgent) => PrincipalKind::LocalAgent,
|
||||
Ok(WirePrincipalKind::AccessToken) => PrincipalKind::AccessToken,
|
||||
Ok(WirePrincipalKind::Unspecified) | Err(_) => {
|
||||
return Err(AccessError::Unavailable(
|
||||
"identity returned an invalid principal kind".to_owned(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(Principal { id, kind })
|
||||
}
|
||||
|
||||
async fn require_organization_admin(
|
||||
&self,
|
||||
principal: Principal,
|
||||
organization_id: Uuid,
|
||||
) -> Result<(), AccessError> {
|
||||
self.require(
|
||||
principal,
|
||||
ResourceKind::Organization,
|
||||
organization_id,
|
||||
"admin",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_readable_repository(
|
||||
&self,
|
||||
principal: Principal,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
) -> Result<RepositoryCoordinates, AccessError> {
|
||||
let response = self
|
||||
.client
|
||||
.clone()
|
||||
.resolve_repository(self.request(ResolveRepositoryRequest {
|
||||
owner: owner.to_owned(),
|
||||
name: name.to_owned(),
|
||||
}))
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
let id = response.repository_id.parse().map_err(|_| {
|
||||
AccessError::Unavailable("identity returned an invalid repository".to_owned())
|
||||
})?;
|
||||
self.require(principal, ResourceKind::Repository, id, "repo:read")
|
||||
.await?;
|
||||
Ok(RepositoryCoordinates {
|
||||
id,
|
||||
owner: owner.to_owned(),
|
||||
name: name.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn repository_coordinates(
|
||||
&self,
|
||||
principal: Principal,
|
||||
repository_id: Uuid,
|
||||
) -> Result<RepositoryCoordinates, AccessError> {
|
||||
self.require(
|
||||
principal,
|
||||
ResourceKind::Repository,
|
||||
repository_id,
|
||||
"repo:read",
|
||||
)
|
||||
.await?;
|
||||
let response = self
|
||||
.client
|
||||
.clone()
|
||||
.get_repository_coordinates(self.request(GetRepositoryCoordinatesRequest {
|
||||
repository_id: repository_id.to_string(),
|
||||
}))
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
Ok(RepositoryCoordinates {
|
||||
id: repository_id,
|
||||
owner: response.owner,
|
||||
name: response.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_principal_kind(kind: PrincipalKind) -> WirePrincipalKind {
|
||||
match kind {
|
||||
PrincipalKind::User => WirePrincipalKind::User,
|
||||
PrincipalKind::PlatformAgent => WirePrincipalKind::PlatformAgent,
|
||||
PrincipalKind::LocalAgent => WirePrincipalKind::LocalAgent,
|
||||
PrincipalKind::AccessToken => WirePrincipalKind::AccessToken,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_status(status: tonic::Status) -> AccessError {
|
||||
match status.code() {
|
||||
Code::Unauthenticated => AccessError::Unauthenticated,
|
||||
Code::PermissionDenied => AccessError::Denied,
|
||||
Code::NotFound => AccessError::NotFound,
|
||||
_ => AccessError::Unavailable(status.to_string()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user