termivar_scanner/api_observation/
query.rs1use serde::{Deserialize, Deserializer, Serialize};
2use std::fmt;
3use termivar_core::{EntityId, RelationId};
4
5use crate::{
6 api_observation::{
7 cursor::ApiVisibilityReviewCursor,
8 model::ApiObservationError,
9 review::{project_api_visibility_review, ApiVisibilityReview},
10 DEFAULT_API_VISIBILITY_REVIEW_SCAN_LIMIT, HARD_MAX_API_VISIBILITY_REVIEW_SCAN_LIMIT,
11 },
12 knowledge::{KnowledgeBase, MAX_KNOWLEDGE_RELATION_ID_BYTES},
13};
14
15#[derive(Clone, PartialEq, Eq, Serialize)]
27pub struct ApiVisibilityReviewQuery {
28 after_relation_id: Option<RelationId>,
29 scan_limit: u16,
30}
31
32impl fmt::Debug for ApiVisibilityReviewQuery {
33 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34 formatter
35 .debug_struct("ApiVisibilityReviewQuery")
36 .field(
37 "after_relation_id",
38 &self.after_relation_id.as_ref().map(|_| "<redacted>"),
39 )
40 .field("scan_limit", &self.scan_limit)
41 .finish()
42 }
43}
44
45impl ApiVisibilityReviewQuery {
46 pub fn new(scan_limit: u16) -> Result<Self, ApiObservationError> {
48 if scan_limit == 0 {
49 return Err(ApiObservationError::ZeroReviewScanLimit);
50 }
51 if scan_limit > HARD_MAX_API_VISIBILITY_REVIEW_SCAN_LIMIT {
52 return Err(ApiObservationError::ReviewScanLimitExceeded {
53 actual: scan_limit,
54 maximum: HARD_MAX_API_VISIBILITY_REVIEW_SCAN_LIMIT,
55 });
56 }
57 Ok(Self {
58 after_relation_id: None,
59 scan_limit,
60 })
61 }
62
63 pub fn after_relation_id(
69 mut self,
70 relation_id: RelationId,
71 ) -> Result<Self, ApiObservationError> {
72 if relation_id.as_str().len() > MAX_KNOWLEDGE_RELATION_ID_BYTES {
73 return Err(ApiObservationError::ReviewCursorTooLong {
74 actual: relation_id.as_str().len(),
75 maximum: MAX_KNOWLEDGE_RELATION_ID_BYTES,
76 });
77 }
78 self.after_relation_id = Some(relation_id);
79 Ok(self)
80 }
81
82 pub fn after(&self) -> Option<&RelationId> {
84 self.after_relation_id.as_ref()
85 }
86
87 pub const fn scan_limit(&self) -> u16 {
89 self.scan_limit
90 }
91}
92
93impl Default for ApiVisibilityReviewQuery {
94 fn default() -> Self {
95 Self {
96 after_relation_id: None,
97 scan_limit: DEFAULT_API_VISIBILITY_REVIEW_SCAN_LIMIT,
98 }
99 }
100}
101
102impl<'de> Deserialize<'de> for ApiVisibilityReviewQuery {
103 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
104 where
105 D: Deserializer<'de>,
106 {
107 #[derive(Deserialize)]
108 #[serde(deny_unknown_fields)]
109 struct WireQuery {
110 after_relation_id: Option<RelationId>,
111 scan_limit: u16,
112 }
113
114 let wire = WireQuery::deserialize(deserializer)?;
115 let mut query = Self::new(wire.scan_limit).map_err(serde::de::Error::custom)?;
116 if let Some(relation_id) = wire.after_relation_id {
117 query = query
118 .after_relation_id(relation_id)
119 .map_err(serde::de::Error::custom)?;
120 }
121 Ok(query)
122 }
123}
124
125#[derive(Clone, PartialEq, Eq, Serialize)]
127pub struct ApiVisibilityReviewPage {
128 resource_scope: EntityId,
129 reviews: Vec<ApiVisibilityReview>,
130 scanned_relations: u16,
131 next_after_relation_id: Option<RelationId>,
132}
133
134impl fmt::Debug for ApiVisibilityReviewPage {
135 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136 formatter
137 .debug_struct("ApiVisibilityReviewPage")
138 .field("resource_scope", &"<redacted>")
139 .field("reviews", &self.reviews)
140 .field("scanned_relations", &self.scanned_relations)
141 .field(
142 "next_after_relation_id",
143 &self.next_after_relation_id.as_ref().map(|_| "<redacted>"),
144 )
145 .finish()
146 }
147}
148
149impl ApiVisibilityReviewPage {
150 pub fn resource_scope(&self) -> &EntityId {
152 &self.resource_scope
153 }
154
155 pub fn reviews(&self) -> &[ApiVisibilityReview] {
157 &self.reviews
158 }
159
160 pub const fn scanned_relations(&self) -> u16 {
162 self.scanned_relations
163 }
164
165 pub fn next_after_relation_id(&self) -> Option<&RelationId> {
171 self.next_after_relation_id.as_ref()
172 }
173
174 pub fn next_cursor(&self) -> Result<Option<ApiVisibilityReviewCursor>, ApiObservationError> {
180 self.next_after_relation_id
181 .as_ref()
182 .map(|relation_id| {
183 ApiVisibilityReviewCursor::new(&self.resource_scope, relation_id.clone())
184 })
185 .transpose()
186 }
187
188 pub fn into_reviews(self) -> Vec<ApiVisibilityReview> {
190 self.reviews
191 }
192}
193
194pub fn api_visibility_reviews_for_resource_v2(
203 knowledge: &KnowledgeBase,
204 resource_scope: &EntityId,
205 cursor: Option<&ApiVisibilityReviewCursor>,
206 scan_limit: u16,
207) -> Result<ApiVisibilityReviewPage, ApiObservationError> {
208 let mut query = ApiVisibilityReviewQuery::new(scan_limit)?;
209 if let Some(cursor) = cursor {
210 if !cursor.matches_resource(resource_scope) {
211 return Err(ApiObservationError::ResourceBoundReviewCursorMismatch);
212 }
213 query = query.after_relation_id(cursor.after_relation_id().clone())?;
214 }
215 Ok(api_visibility_reviews_for_resource(
216 knowledge,
217 resource_scope,
218 &query,
219 ))
220}
221
222pub fn api_visibility_reviews_for_resource(
238 knowledge: &KnowledgeBase,
239 resource_scope: &EntityId,
240 query: &ApiVisibilityReviewQuery,
241) -> ApiVisibilityReviewPage {
242 let scan_limit = usize::from(query.scan_limit());
243 let (relations, has_more) =
244 knowledge.relations_to_page_with_more(resource_scope, query.after(), scan_limit);
245 let scanned_relations =
246 u16::try_from(relations.len()).expect("validated review scan limits always fit in u16");
247 let next_after_relation_id = has_more
248 .then(|| relations.last().map(|relation| relation.id().clone()))
249 .flatten();
250
251 let mut reviews = Vec::new();
252 for relation in relations {
253 if let Some(review) = project_api_visibility_review(knowledge, resource_scope, &relation) {
254 reviews.push(review);
255 }
256 }
257 ApiVisibilityReviewPage {
258 resource_scope: resource_scope.clone(),
259 reviews,
260 scanned_relations,
261 next_after_relation_id,
262 }
263}