···33pub mod error;
44pub mod file_consumer;
55pub mod index_html;
66-pub mod qs_query;
76pub mod server;
87pub mod storage;
98pub mod storage_fjall;
-73
ufos/src/qs_query.rs
···11-use async_trait::async_trait;
22-use dropshot::{
33- ApiEndpointBodyContentType, ExclusiveExtractor, ExtractorMetadata, HttpError, RequestContext,
44- RequestInfo, ServerContext, SharedExtractor,
55-};
66-/// copied from https://github.com/oxidecomputer/dropshot/blob/695e1d8872c988c43066eb0848c87c127eeda361/dropshot/src/extractor/query.rs
77-/// Apache 2.0: https://github.com/oxidecomputer/dropshot/blob/695e1d8872c988c43066eb0848c87c127eeda361/LICENSE
88-use schemars::JsonSchema;
99-use serde::de::DeserializeOwned;
1010-1111-/// `VecsAllowedQuery<QueryType>` is an extractor used to deserialize an
1212-/// instance of `QueryType` from an HTTP request's query string. `QueryType`
1313-/// is any structure of yours that implements [serde::Deserialize] and
1414-/// [schemars::JsonSchema]. See the crate documentation for more information.
1515-#[derive(Debug)]
1616-pub struct VecsAllowedQuery<QueryType: DeserializeOwned + JsonSchema + Send + Sync> {
1717- inner: QueryType,
1818-}
1919-impl<QueryType: DeserializeOwned + JsonSchema + Send + Sync> VecsAllowedQuery<QueryType> {
2020- // TODO drop this in favor of Deref? + Display and Debug for convenience?
2121- pub fn into_inner(self) -> QueryType {
2222- self.inner
2323- }
2424-}
2525-2626-/// Given an HTTP request, pull out the query string and attempt to deserialize
2727-/// it as an instance of `QueryType`.
2828-fn http_request_load_query<QueryType>(
2929- request: &RequestInfo,
3030-) -> Result<VecsAllowedQuery<QueryType>, HttpError>
3131-where
3232- QueryType: DeserializeOwned + JsonSchema + Send + Sync,
3333-{
3434- let raw_query_string = request.uri().query().unwrap_or("");
3535- // TODO-correctness: are query strings defined to be urlencoded in this way?
3636- match serde_qs::from_str(raw_query_string) {
3737- Ok(q) => Ok(VecsAllowedQuery { inner: q }),
3838- Err(e) => Err(HttpError::for_bad_request(
3939- None,
4040- format!("unable to parse query string: {}", e),
4141- )),
4242- }
4343-}
4444-4545-// The `SharedExtractor` implementation for Query<QueryType> describes how to
4646-// construct an instance of `Query<QueryType>` from an HTTP request: namely, by
4747-// parsing the query string to an instance of `QueryType`.
4848-// TODO-cleanup We shouldn't have to use the "'static" bound on `QueryType`
4949-// here. It seems like we ought to be able to use 'async_trait, but that
5050-// doesn't seem to be defined.
5151-#[async_trait]
5252-impl<QueryType> SharedExtractor for VecsAllowedQuery<QueryType>
5353-where
5454- QueryType: JsonSchema + DeserializeOwned + Send + Sync + 'static,
5555-{
5656- async fn from_request<Context: ServerContext>(
5757- rqctx: &RequestContext<Context>,
5858- ) -> Result<VecsAllowedQuery<QueryType>, HttpError> {
5959- http_request_load_query(&rqctx.request)
6060- }
6161-6262- fn metadata(body_content_type: ApiEndpointBodyContentType) -> ExtractorMetadata {
6363- // HACK: would love to use Query here but it "helpfully" panics when it sees a Vec.
6464- // we can't really get at enough of Query's logic to use it directly, sadly, so the
6565- // resulting openapi docs suck (query params are listed as body payload, example
6666- // codes make no sense, etc.)
6767- //
6868- // trying to hack the resulting ExtractorMetadata to look like Query's is a pain:
6969- // things almost work out but then something in dropshot won't be `pub` and it falls
7070- // apart. maybe it's possible, i didn't get it in the time i had.
7171- dropshot::TypedBody::<QueryType>::metadata(body_content_type)
7272- }
7373-}
+26-46
ufos/src/server.rs
ufos/src/server/mod.rs
···11+mod collections_query;
22+mod cors;
33+14use crate::index_html::INDEX_HTML;
22-use crate::qs_query::VecsAllowedQuery;
35use crate::storage::StoreReader;
46use crate::store_types::{HourTruncatedCursor, WeekTruncatedCursor};
57use crate::{ConsumerInfo, Cursor, JustCount, Nsid, NsidCount, OrderCollectionsBy, UFOsRecord};
68use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
79use chrono::{DateTime, Utc};
1010+use collections_query::MultiCollectionQuery;
1111+use cors::{OkCors, OkCorsResponse};
812use dropshot::endpoint;
913use dropshot::ApiDescription;
1014use dropshot::Body;
···1216use dropshot::ConfigLogging;
1317use dropshot::ConfigLoggingLevel;
1418use dropshot::HttpError;
1515-use dropshot::HttpResponseHeaders;
1616-use dropshot::HttpResponseOk;
1719use dropshot::Query;
1820use dropshot::RequestContext;
1921use dropshot::ServerBuilder;
···7678}]
7779async fn get_openapi(ctx: RequestContext<Context>) -> OkCorsResponse<serde_json::Value> {
7880 let spec = (*ctx.context().spec).clone();
7979- ok_cors(spec)
8181+ OkCors(spec).into()
8082}
81838284#[derive(Debug, Serialize, JsonSchema)]
···105107 .await
106108 .map_err(failed_to_get("consumer info"))?;
107109108108- ok_cors(MetaInfo {
110110+ OkCors(MetaInfo {
109111 storage_name: storage.name(),
110112 storage: storage_info,
111113 consumer,
112114 })
115115+ .into()
113116}
114117115118// TODO: replace with normal (🙃) multi-qs value somehow
···194197 .map(|r| r.into())
195198 .collect();
196199197197- ok_cors(records)
200200+ OkCors(records).into()
198201}
199202200203#[derive(Debug, Deserialize, JsonSchema)]
201201-struct TotalSeenCollectionsQuery {
202202- collection: Vec<String>, // JsonSchema not implemented for Nsid :(
204204+struct CollectionsStatsQuery {
203205 /// Limit stats to those seen after this UTC datetime
204206 ///
205207 /// default: 1 week ago
···216218}
217219/// Collection stats
218220///
219219-/// Get stats for a collection over a specific time period
220220-///
221221-/// API docs note: the **Body** fields here are actually query parameters!!
222222-///
223223-/// Due to limitations with dropshot's query parsing (no support for sequences),
224224-/// this is kind of the best i could do for now. sadly.
221221+/// Get record statistics for collections during a specific time period
225222#[endpoint {
226223 method = GET,
227224 path = "/collections/stats"
228225}]
229229-async fn get_records_total_seen(
226226+async fn get_collection_stats(
230227 ctx: RequestContext<Context>,
231231- query: VecsAllowedQuery<TotalSeenCollectionsQuery>,
228228+ collections_query: MultiCollectionQuery,
229229+ query: Query<CollectionsStatsQuery>,
232230) -> OkCorsResponse<HashMap<String, TotalCounts>> {
233231 let Context { storage, .. } = ctx.context();
234232 let q = query.into_inner();
235235-236236- log::warn!("collection: {:?}", q.collection);
237237-238238- let mut collections = Vec::with_capacity(q.collection.len());
239239- for c in q.collection {
240240- let Ok(nsid) = Nsid::new(c.clone()) else {
241241- return Err(HttpError::for_bad_request(
242242- None,
243243- format!("could not parse collection to nsid: {c}"),
244244- ));
245245- };
246246- collections.push(nsid);
247247- }
233233+ let collections: HashSet<Nsid> = collections_query.try_into()?;
248234249249- let since = q.since.map(dt_to_cursor).transpose()?;
250250- let until = q.until.map(dt_to_cursor).transpose()?;
235235+ let _since = q.since.map(dt_to_cursor).transpose()?;
236236+ let _until = q.until.map(dt_to_cursor).transpose()?;
251237252238 let mut seen_by_collection = HashMap::with_capacity(collections.len());
253239···266252 );
267253 }
268254269269- ok_cors(seen_by_collection)
255255+ OkCors(seen_by_collection).into()
270256}
271257272258#[derive(Debug, Serialize, JsonSchema)]
···315301 order: Option<CollectionsQueryOrder>,
316302}
317303318318-/// List collections (with stats)
304304+/// List collections
305305+///
306306+/// With statistics.
319307///
320308/// ## To fetch a full list:
321309///
···385373386374 let next_cursor = next_cursor.map(|c| URL_SAFE_NO_PAD.encode(c));
387375388388- ok_cors(CollectionsResponse {
376376+ OkCors(CollectionsResponse {
389377 collections,
390378 cursor: next_cursor,
391379 })
380380+ .into()
392381}
393382394383#[derive(Debug, Deserialize, JsonSchema)]
···439428 let step = if let Some(secs) = q.step {
440429 if secs < 3600 {
441430 let msg = format!("step is too small: {}", secs);
442442- return Err(HttpError::for_bad_request(None, msg));
431431+ Err(HttpError::for_bad_request(None, msg))?;
443432 }
444433 (secs / 3600) * 3600 // trucate to hour
445434 } else {
···465454 .map(|(k, v)| (k.to_string(), v.iter().map(Into::into).collect()))
466455 .collect();
467456468468- ok_cors(CollectionTimeseriesResponse { range, series })
457457+ OkCors(CollectionTimeseriesResponse { range, series }).into()
469458}
470459471460pub async fn serve(storage: impl StoreReader + 'static) -> Result<(), String> {
···481470 api.register(get_openapi).unwrap();
482471 api.register(get_meta_info).unwrap();
483472 api.register(get_records_by_collections).unwrap();
484484- api.register(get_records_total_seen).unwrap();
473473+ api.register(get_collection_stats).unwrap();
485474 api.register(get_collections).unwrap();
486475 api.register(get_timeseries).unwrap();
487476···514503 .map_err(|error| format!("failed to start server: {}", error))?
515504 .await
516505}
517517-518518-/// awkward helpers
519519-type OkCorsResponse<T> = Result<HttpResponseHeaders<HttpResponseOk<T>>, HttpError>;
520520-fn ok_cors<T: Send + Sync + Serialize + JsonSchema>(t: T) -> OkCorsResponse<T> {
521521- let mut res = HttpResponseHeaders::new_unnamed(HttpResponseOk(t));
522522- res.headers_mut()
523523- .insert("access-control-allow-origin", "*".parse().unwrap());
524524- Ok(res)
525525-}
+72
ufos/src/server/collections_query.rs
···11+use crate::Nsid;
22+use async_trait::async_trait;
33+use dropshot::{
44+ ApiEndpointBodyContentType, ExtractorMetadata, HttpError, Query, RequestContext, ServerContext,
55+ SharedExtractor,
66+};
77+use schemars::JsonSchema;
88+use serde::Deserialize;
99+use std::collections::HashSet;
1010+1111+/// The real type that gets deserialized
1212+#[derive(Debug, Deserialize, JsonSchema)]
1313+pub struct MultiCollectionQuery {
1414+ pub collection: Vec<String>,
1515+}
1616+1717+/// The fake corresponding type for docs that dropshot won't freak out about a
1818+/// vec for
1919+#[derive(Deserialize, JsonSchema)]
2020+#[allow(dead_code)]
2121+struct MultiCollectionQueryForDocs {
2222+ /// One or more collection [NSID](https://atproto.com/specs/nsid)s
2323+ ///
2424+ /// Pass this parameter multiple times to specify multiple collections, like
2525+ /// `collection=app.bsky.feed.like&collection=app.bsky.feed.post`
2626+ collection: String,
2727+}
2828+2929+impl TryFrom<MultiCollectionQuery> for HashSet<Nsid> {
3030+ type Error = HttpError;
3131+ fn try_from(mcq: MultiCollectionQuery) -> Result<Self, Self::Error> {
3232+ let mut out = HashSet::with_capacity(mcq.collection.len());
3333+ for c in mcq.collection {
3434+ let nsid = Nsid::new(c).map_err(|e| {
3535+ HttpError::for_bad_request(
3636+ None,
3737+ format!("failed to convert collection to an NSID: {e:?}"),
3838+ )
3939+ })?;
4040+ out.insert(nsid);
4141+ }
4242+ Ok(out)
4343+ }
4444+}
4545+4646+// The `SharedExtractor` implementation for Query<QueryType> describes how to
4747+// construct an instance of `Query<QueryType>` from an HTTP request: namely, by
4848+// parsing the query string to an instance of `QueryType`.
4949+#[async_trait]
5050+impl SharedExtractor for MultiCollectionQuery {
5151+ async fn from_request<Context: ServerContext>(
5252+ ctx: &RequestContext<Context>,
5353+ ) -> Result<MultiCollectionQuery, HttpError> {
5454+ let raw_query = ctx.request.uri().query().unwrap_or("");
5555+ let q = serde_qs::from_str(raw_query).map_err(|e| {
5656+ HttpError::for_bad_request(None, format!("unable to parse query string: {}", e))
5757+ })?;
5858+ Ok(q)
5959+ }
6060+6161+ fn metadata(body_content_type: ApiEndpointBodyContentType) -> ExtractorMetadata {
6262+ // HACK: query type switcheroo: passing MultiCollectionQuery to
6363+ // `metadata` would "helpfully" panic because dropshot believes we can
6464+ // only have scalar types in a query.
6565+ //
6666+ // so instead we have a fake second type whose only job is to look the
6767+ // same as MultiCollectionQuery exept that it has `String` instead of
6868+ // `Vec<String>`, which dropshot will accept, and generate ~close-enough
6969+ // docs for.
7070+ <Query<MultiCollectionQueryForDocs> as SharedExtractor>::metadata(body_content_type)
7171+ }
7272+}
+23
ufos/src/server/cors.rs
···11+use dropshot::{HttpError, HttpResponseHeaders, HttpResponseOk};
22+use schemars::JsonSchema;
33+use serde::Serialize;
44+55+pub type OkCorsResponse<T> = Result<HttpResponseHeaders<HttpResponseOk<T>>, HttpError>;
66+77+/// Helper for constructing Ok responses: return OkCors(T).into()
88+/// (not happy with this yet)
99+pub struct OkCors<T: Serialize + JsonSchema + Send + Sync>(pub T);
1010+1111+impl<T> From<OkCors<T>> for OkCorsResponse<T>
1212+where
1313+ T: Serialize + JsonSchema + Send + Sync,
1414+{
1515+ fn from(ok: OkCors<T>) -> OkCorsResponse<T> {
1616+ let mut res = HttpResponseHeaders::new_unnamed(HttpResponseOk(ok.0));
1717+ res.headers_mut()
1818+ .insert("access-control-allow-origin", "*".parse().unwrap());
1919+ Ok(res)
2020+ }
2121+}
2222+2323+// TODO: cors for HttpError