···33pub mod error;
44pub mod file_consumer;
55pub mod index_html;
66+pub mod qs_query;
67pub mod server;
78pub mod storage;
89pub 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+}
+43-11
ufos/src/server.rs
···11use crate::index_html::INDEX_HTML;
22+use crate::qs_query::VecsAllowedQuery;
23use crate::storage::StoreReader;
34use crate::store_types::{HourTruncatedCursor, WeekTruncatedCursor};
45use crate::{ConsumerInfo, Cursor, JustCount, Nsid, NsidCount, OrderCollectionsBy, UFOsRecord};
···1617use dropshot::Query;
1718use dropshot::RequestContext;
1819use dropshot::ServerBuilder;
2020+1921use http::{Response, StatusCode};
2022use schemars::JsonSchema;
2123use serde::{Deserialize, Serialize};
···8385 storage: serde_json::Value,
8486 consumer: ConsumerInfo,
8587}
8686-/// Get meta information about UFOs itself
8888+/// UFOs meta-info
8789#[endpoint {
8890 method = GET,
8991 path = "/meta"
···145147 }
146148 }
147149}
148148-/// Get recent records by collection
150150+/// Record samples
151151+///
152152+/// Get most recent records seen in the firehose, by collection NSID
149153///
150154/// Multiple collections are supported. They will be delivered in one big array with no
151155/// specified order.
···195199196200#[derive(Debug, Deserialize, JsonSchema)]
197201struct TotalSeenCollectionsQuery {
198198- collection: String, // JsonSchema not implemented for Nsid :(
202202+ collection: Vec<String>, // JsonSchema not implemented for Nsid :(
203203+ /// Limit stats to those seen after this UTC datetime
204204+ ///
205205+ /// default: 1 week ago
206206+ since: Option<DateTime<Utc>>,
207207+ /// Limit stats to those seen before this UTC datetime
208208+ ///
209209+ /// default: now
210210+ until: Option<DateTime<Utc>>,
199211}
200212#[derive(Debug, Serialize, JsonSchema)]
201213struct TotalCounts {
202214 total_creates: u64,
203215 dids_estimate: u64,
204216}
205205-/// Get total records seen by collection
217217+/// Collection stats
218218+///
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.
206225#[endpoint {
207226 method = GET,
208208- path = "/records/total-seen"
227227+ path = "/collections/stats"
209228}]
210229async fn get_records_total_seen(
211230 ctx: RequestContext<Context>,
212212- collection_query: Query<TotalSeenCollectionsQuery>,
231231+ query: VecsAllowedQuery<TotalSeenCollectionsQuery>,
213232) -> OkCorsResponse<HashMap<String, TotalCounts>> {
214233 let Context { storage, .. } = ctx.context();
234234+ let q = query.into_inner();
215235216216- let query = collection_query.into_inner();
217217- let collections = to_multiple_nsids(&query.collection)
218218- .map_err(|reason| HttpError::for_bad_request(None, reason))?;
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+ }
248248+249249+ let since = q.since.map(dt_to_cursor).transpose()?;
250250+ let until = q.until.map(dt_to_cursor).transpose()?;
219251220252 let mut seen_by_collection = HashMap::with_capacity(collections.len());
221253···283315 order: Option<CollectionsQueryOrder>,
284316}
285317286286-/// Get collection with statistics
318318+/// List collections (with stats)
287319///
288320/// ## To fetch a full list:
289321///
···384416 range: Vec<DateTime<Utc>>,
385417 series: HashMap<String, Vec<JustCount>>,
386418}
387387-/// Get timeseries data
419419+/// Collection timeseries stats
388420#[endpoint {
389421 method = GET,
390422 path = "/timeseries"