relay_server/endpoints/
project_configs.rs1use std::collections::HashMap;
2use std::sync::Arc;
3
4use axum::extract::{Query, Request};
5use axum::handler::Handler;
6use axum::http::StatusCode;
7use axum::response::{IntoResponse, Result};
8use axum::{Json, RequestExt};
9use relay_base_schema::project::ProjectKey;
10use relay_dynamic_config::{ErrorBoundary, GlobalConfig};
11use serde::{Deserialize, Serialize};
12
13use crate::endpoints::common::ServiceUnavailable;
14use crate::endpoints::forward;
15use crate::extractors::SignedJson;
16use crate::service::ServiceState;
17use crate::services::global_config::{self, StatusResponse};
18use crate::services::projects::project::{
19 LimitedParsedProjectState, ParsedProjectState, ProjectState, Revision,
20};
21use crate::utils::ApiErrorResponse;
22
23const ENDPOINT_V3: u16 = 3;
30
31#[derive(Debug, Clone, Copy, thiserror::Error)]
32#[error("This API version is no longer supported, upgrade your Relay or Client")]
33struct VersionOutdatedError;
34
35impl IntoResponse for VersionOutdatedError {
36 fn into_response(self) -> axum::response::Response {
37 (StatusCode::BAD_REQUEST, ApiErrorResponse::from_error(&self)).into_response()
38 }
39}
40
41#[derive(Clone, Copy, Debug, Deserialize)]
43struct VersionQuery {
44 #[serde(default)]
45 version: u16,
46}
47
48#[derive(Debug, Clone, Serialize)]
56#[serde(untagged)]
57enum ProjectStateWrapper {
58 Full(ParsedProjectState),
59 Limited(#[serde(with = "LimitedParsedProjectState")] ParsedProjectState),
60}
61
62impl ProjectStateWrapper {
63 pub fn new(state: ParsedProjectState, full: bool) -> Self {
65 if full {
66 Self::Full(state)
67 } else {
68 Self::Limited(state)
69 }
70 }
71}
72
73#[derive(Debug, Serialize)]
83#[serde(rename_all = "camelCase")]
84struct GetProjectStatesResponseWrapper {
85 configs: HashMap<ProjectKey, ProjectStateWrapper>,
86 #[serde(skip_serializing_if = "Vec::is_empty")]
87 pending: Vec<ProjectKey>,
88 #[serde(skip_serializing_if = "Vec::is_empty")]
89 unchanged: Vec<ProjectKey>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 global: Option<Arc<GlobalConfig>>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 global_status: Option<StatusResponse>,
94}
95
96#[derive(Debug, Deserialize)]
101#[serde(rename_all = "camelCase")]
102struct GetProjectStatesRequest {
103 public_keys: Vec<ErrorBoundary<ProjectKey>>,
105 revisions: Option<ErrorBoundary<Vec<Revision>>>,
110 #[serde(default)]
111 full_config: bool,
112 #[serde(default)]
113 global: bool,
114}
115
116fn into_valid_keys(
117 public_keys: Vec<ErrorBoundary<ProjectKey>>,
118 revisions: Option<ErrorBoundary<Vec<Revision>>>,
119) -> impl Iterator<Item = (ProjectKey, Revision)> {
120 let mut revisions = revisions.and_then(|e| e.ok()).unwrap_or_default();
121 if !revisions.is_empty() && revisions.len() != public_keys.len() {
122 relay_log::warn!(
126 "downstream sent {} project keys but {} revisions, discarding all revisions",
127 public_keys.len(),
128 revisions.len()
129 );
130 revisions.clear();
131 }
132 let revisions = revisions
133 .into_iter()
134 .chain(std::iter::repeat_with(Revision::default));
135
136 std::iter::zip(public_keys, revisions).filter_map(|(public_key, revision)| {
137 let public_key = public_key.ok()?;
140 Some((public_key, revision))
141 })
142}
143
144async fn inner(
145 state: ServiceState,
146 body: SignedJson<GetProjectStatesRequest>,
147) -> Result<impl IntoResponse, ServiceUnavailable> {
148 let SignedJson { inner, relay } = body;
149
150 let (global, global_status) = if inner.global {
151 match state.global_config().send(global_config::Get).await? {
152 global_config::Status::Ready(config) => (Some(config), Some(StatusResponse::Ready)),
153 global_config::Status::Pending => (
156 Some(GlobalConfig::default().into()),
157 Some(StatusResponse::Pending),
158 ),
159 }
160 } else {
161 (None, None)
162 };
163
164 let keys_len = inner.public_keys.len();
165 let mut pending = Vec::with_capacity(keys_len);
166 let mut unchanged = Vec::with_capacity(keys_len);
167 let mut configs = HashMap::with_capacity(keys_len);
168
169 for (project_key, revision) in into_valid_keys(inner.public_keys, inner.revisions) {
170 let project = state.project_cache_handle().get(project_key);
171
172 let project_info = match project.state() {
173 ProjectState::Enabled(info) => info,
174 ProjectState::Disabled => {
175 continue;
177 }
178 ProjectState::Pending => {
179 pending.push(project_key);
180 continue;
181 }
182 };
183
184 if project_info.rev == revision {
186 unchanged.push(project_key);
187 continue;
188 }
189
190 let has_access = relay.internal
193 || project_info
194 .config
195 .trusted_relays
196 .contains(&relay.public_key);
197
198 if has_access {
199 let full = relay.internal && inner.full_config;
200 let wrapper = ProjectStateWrapper::new(
201 ParsedProjectState {
202 disabled: false,
203 info: project_info.as_ref().clone(),
204 },
205 full,
206 );
207 configs.insert(project_key, wrapper);
208 } else {
209 relay_log::debug!(
210 relay = %relay.public_key,
211 project_key = %project_key,
212 "relay does not have access to project key",
213 );
214 };
215 }
216
217 Ok(Json(GetProjectStatesResponseWrapper {
218 configs,
219 pending,
220 unchanged,
221 global,
222 global_status,
223 }))
224}
225
226fn is_outdated(Query(query): Query<VersionQuery>) -> bool {
228 query.version < ENDPOINT_V3
229}
230
231fn is_compatible(Query(query): Query<VersionQuery>) -> bool {
233 query.version == ENDPOINT_V3
234}
235
236pub async fn handle(state: ServiceState, mut req: Request) -> Result<impl IntoResponse> {
248 let data = req.extract_parts().await?;
249 if is_outdated(data) {
250 Err(VersionOutdatedError.into())
251 } else if is_compatible(data) {
252 Ok(inner.call(req, state).await)
253 } else {
254 Ok(forward::forward(state, req).await)
255 }
256}