1use crate::{Api, Operation, SchemaKind, SchemaValue};
3use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub enum PaginationKind {
10 Cursor,
11 OffsetLimit,
12 Page,
13 Url,
14}
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct PaginationRule {
20 #[serde(rename = "type")]
21 pub kind: PaginationKind,
22 #[serde(default)]
23 pub inputs: Vec<PaginationInputRule>,
24 pub outputs: std::collections::BTreeMap<String, String>,
25}
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct PaginationInputRule {
29 pub name: String,
30 #[serde(rename = "type")]
31 pub kind: String,
32 #[serde(rename = "in", default = "parameters")]
33 pub location: String,
34}
35fn parameters() -> String {
36 "parameters".into()
37}
38
39#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
40pub struct PaginationPlan {
41 pub kind: PaginationKind,
42 pub inputs: Vec<PaginationInput>,
43 pub results: Option<Selector>,
44 pub continuation: Option<Selector>,
45 pub same_origin: bool,
47}
48#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
49pub enum PaginationValueKind {
50 String,
51 Integer,
52}
53#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
54pub struct PaginationInput {
55 pub name: String,
56 pub role: String,
57 pub location: String,
58 pub required: bool,
59 pub value_kind: PaginationValueKind,
60}
61#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
62pub struct Selector {
63 pub expression: String,
64 pub segments: Vec<SelectorSegment>,
65}
66#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
67pub enum SelectorSegment {
68 Field(String),
69 Index(i64),
70}
71
72fn pointer_index(name: &str) -> Option<usize> {
73 if name.is_empty()
74 || (name.len() > 1 && name.starts_with('0'))
75 || !name.bytes().all(|byte| byte.is_ascii_digit())
76 {
77 return None;
78 }
79 name.parse().ok()
80}
81
82impl Selector {
83 pub fn parse(expression: &str) -> Result<Self> {
86 let mut segments = Vec::new();
87 if let Some(pointer) = expression.strip_prefix('/') {
88 for part in pointer.split('/') {
89 let mut decoded = String::new();
90 let mut chars = part.chars();
91 while let Some(ch) = chars.next() {
92 if ch == '~' {
93 decoded.push(match chars.next() {
94 Some('0') => '~',
95 Some('1') => '/',
96 _ => bail!("invalid JSON pointer escape"),
97 });
98 } else {
99 decoded.push(ch);
100 }
101 }
102 segments.push(SelectorSegment::Field(decoded));
103 }
104 } else {
105 let mut rest = expression
106 .strip_prefix('$')
107 .context("pagination selector must start with $ or /")?;
108 while !rest.is_empty() {
109 if let Some(after) = rest.strip_prefix('.') {
110 let end = after.find(['.', '[']).unwrap_or(after.len());
111 let name = &after[..end];
112 if name.is_empty()
113 || name.chars().any(|ch| {
114 ch.is_whitespace() || matches!(ch, '*' | '?' | ']' | '(' | ')' | '$')
115 })
116 {
117 bail!("unsupported pagination selector field");
118 }
119 segments.push(SelectorSegment::Field(name.into()));
120 rest = &after[end..];
121 } else if let Some(after) = rest.strip_prefix('[') {
122 let end = after
123 .find(']')
124 .context("unterminated pagination selector index")?;
125 segments.push(SelectorSegment::Index(
126 after[..end]
127 .parse()
128 .context("pagination selector index must be an integer")?,
129 ));
130 rest = &after[end + 1..];
131 } else {
132 bail!("invalid pagination selector syntax");
133 }
134 }
135 }
136 Ok(Self {
137 expression: expression.into(),
138 segments,
139 })
140 }
141 pub fn select<'a>(&self, value: &'a Value) -> Option<&'a Value> {
142 let mut value = value;
143 for segment in &self.segments {
144 value = match segment {
145 SelectorSegment::Field(name) => match value {
146 Value::Array(items) => items.get(pointer_index(name)?)?,
147 _ => value.get(name)?,
148 },
149 SelectorSegment::Index(index) => {
150 let items = value.as_array()?;
151 let index = if *index < 0 {
152 i64::try_from(items.len()).ok()?.checked_add(*index)?
153 } else {
154 *index
155 };
156 items.get(usize::try_from(index).ok()?)?
157 }
158 };
159 }
160 Some(value)
161 }
162}
163
164pub fn normalize_pagination(
167 api: &Api,
168 operation: &Operation,
169 explicit: Option<&PaginationRule>,
170) -> Result<Option<PaginationPlan>> {
171 let parsed;
172 let rule = if let Some(rule) = explicit {
173 rule
174 } else {
175 let Some(extension) = operation
176 .annotations
177 .get("x-poolster-pagination")
178 .or_else(|| operation.annotations.get("x-speakeasy-pagination"))
179 else {
180 return Ok(None);
181 };
182 parsed = serde_json::from_value::<PaginationRule>(extension.clone())
183 .with_context(|| format!("invalid pagination declaration for {}", operation.id))?;
184 &parsed
185 };
186 if operation
187 .responses
188 .iter()
189 .flat_map(|r| &r.media_types)
190 .any(|media| {
191 media.content_type == "text/event-stream"
192 || media.content_type == "application/octet-stream"
193 })
194 {
195 bail!("pagination requires a decoded JSON response");
196 }
197 let mut roles = match rule.kind {
198 PaginationKind::Cursor => vec!["cursor"],
199 PaginationKind::OffsetLimit => vec!["offset", "limit"],
200 PaginationKind::Page => vec!["page"],
201 PaginationKind::Url => vec![],
202 };
203 if matches!(rule.kind, PaginationKind::Cursor | PaginationKind::Page)
204 && rule.inputs.iter().any(|input| input.kind == "limit")
205 {
206 roles.push("limit");
207 }
208 let mut inputs = Vec::new();
209 for role in &roles {
210 let matches = rule
211 .inputs
212 .iter()
213 .filter(|input| input.kind == *role)
214 .collect::<Vec<_>>();
215 if matches.len() != 1 {
216 bail!("pagination needs exactly one {role} input");
217 }
218 let input = matches[0];
219 let (schema, location, required) = match input.location.as_str() {
220 "parameters" => {
221 let parameters = operation
222 .parameters
223 .iter()
224 .filter(|parameter| parameter.name == input.name)
225 .collect::<Vec<_>>();
226 if parameters.len() != 1 {
227 bail!(
228 "pagination input {} must identify one parameter",
229 input.name
230 );
231 }
232 let parameter = parameters[0];
233 if !matches!(parameter.location.as_str(), "query" | "header" | "path")
234 || (parameter.location == "path" && !parameter.required)
235 {
236 bail!("unsupported pagination parameter location");
237 }
238 (
239 parameter
240 .schema
241 .as_ref()
242 .context("pagination parameter has no schema")?,
243 parameter.location.clone(),
244 parameter.required,
245 )
246 }
247 "requestBody" => {
248 let body = operation
249 .request_body
250 .as_ref()
251 .context("pagination body is required")?;
252 let media = body
253 .media_types
254 .iter()
255 .find(|media| {
256 media.content_type == "application/json"
257 || media.content_type.ends_with("+json")
258 })
259 .context("pagination body must be JSON")?;
260 let schema = resolve(
261 api,
262 media
263 .schema
264 .as_ref()
265 .context("pagination body has no schema")?,
266 )?;
267 let SchemaKind::Object { fields, .. } = &schema.kind else {
268 bail!("pagination body must be an object");
269 };
270 let field = fields
271 .iter()
272 .find(|field| field.name == input.name)
273 .context("pagination body field not found")?;
274 (&field.value, "requestBody".into(), field.required)
275 }
276 _ => bail!("unsupported pagination input location"),
277 };
278 let kind = &resolve(api, schema)?.kind;
279 if (*role == "cursor" && !matches!(kind, SchemaKind::String | SchemaKind::Integer))
280 || (*role != "cursor" && !matches!(kind, SchemaKind::Integer))
281 {
282 bail!("pagination {role} input has incompatible type");
283 }
284 inputs.push(PaginationInput {
285 name: input.name.clone(),
286 role: role.to_string(),
287 location,
288 required,
289 value_kind: if matches!(kind, SchemaKind::String) {
290 PaginationValueKind::String
291 } else {
292 PaginationValueKind::Integer
293 },
294 });
295 }
296 if rule
297 .inputs
298 .iter()
299 .any(|input| !roles.contains(&input.kind.as_str()))
300 {
301 bail!("unknown pagination input role");
302 }
303 let continuation_key = match rule.kind {
304 PaginationKind::Cursor => Some("nextCursor"),
305 PaginationKind::Url => Some("nextUrl"),
306 _ => None,
307 };
308 let continuation = continuation_key
309 .map(|key| {
310 Selector::parse(
311 rule.outputs
312 .get(key)
313 .with_context(|| format!("pagination outputs.{key} is required"))?,
314 )
315 })
316 .transpose()?;
317 let results = rule
318 .outputs
319 .get("results")
320 .map(|path| Selector::parse(path))
321 .transpose()?;
322 if matches!(
323 rule.kind,
324 PaginationKind::OffsetLimit | PaginationKind::Page
325 ) && results.is_none()
326 {
327 bail!("pagination outputs.results is required");
328 }
329 if let Some(schema) = operation
330 .responses
331 .iter()
332 .filter(|response| {
333 response
334 .status
335 .parse::<u16>()
336 .is_ok_and(|status| (200..300).contains(&status))
337 })
338 .flat_map(|response| &response.media_types)
339 .filter(|media| {
340 media.content_type == "application/json" || media.content_type.ends_with("+json")
341 })
342 .find_map(|media| media.schema.as_ref())
343 {
344 if let Some(selector) = &results {
345 if !matches!(
346 resolve(api, selected_schema(api, schema, selector)?)?.kind,
347 SchemaKind::Array { .. }
348 ) {
349 bail!("pagination results selector must resolve to an array");
350 }
351 }
352 if let Some(selector) = &continuation {
353 let kind = &resolve(api, selected_schema(api, schema, selector)?)?.kind;
354 if !matches!(
355 kind,
356 SchemaKind::String | SchemaKind::Integer | SchemaKind::Null
357 ) || (rule.kind == PaginationKind::Url
358 && !matches!(kind, SchemaKind::String | SchemaKind::Null))
359 {
360 bail!("pagination continuation selector has incompatible type");
361 }
362 }
363 }
364 Ok(Some(PaginationPlan {
365 kind: rule.kind.clone(),
366 inputs,
367 results,
368 continuation,
369 same_origin: rule.kind == PaginationKind::Url,
370 }))
371}
372
373fn resolve<'a>(api: &'a Api, mut schema: &'a SchemaValue) -> Result<&'a SchemaValue> {
374 let mut seen = std::collections::BTreeSet::new();
375 while let SchemaKind::Reference { reference } = &schema.kind {
376 if !seen.insert(reference) {
377 bail!("recursive pagination schema reference");
378 }
379 let name = reference
380 .rsplit('/')
381 .next()
382 .unwrap()
383 .replace("~1", "/")
384 .replace("~0", "~");
385 schema = &api
386 .schemas
387 .iter()
388 .find(|schema| schema.name == name)
389 .context("pagination schema reference not found")?
390 .value;
391 }
392 Ok(schema)
393}
394fn selected_schema<'a>(
395 api: &'a Api,
396 mut schema: &'a SchemaValue,
397 selector: &Selector,
398) -> Result<&'a SchemaValue> {
399 for segment in &selector.segments {
400 schema = resolve(api, schema)?;
401 schema = match (&schema.kind, segment) {
402 (SchemaKind::Object { fields, .. }, SelectorSegment::Field(name)) => {
403 &fields
404 .iter()
405 .find(|field| &field.name == name)
406 .context("pagination selector field not found")?
407 .value
408 }
409 (SchemaKind::Array { items }, SelectorSegment::Index(_)) => items,
410 (SchemaKind::Array { items }, SelectorSegment::Field(name))
411 if pointer_index(name).is_some() =>
412 {
413 items
414 }
415 _ => bail!("pagination selector does not match response schema"),
416 };
417 }
418 Ok(schema)
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use crate::{
425 AdditionalProperties, Field, OperationMediaType, OperationParameter, OperationResponse,
426 };
427 use serde_json::json;
428 fn operation() -> Operation {
429 Operation {
430 id: "list".into(),
431 parameters: vec![OperationParameter { name: "cursor".into(), location: "query".into(), required: false, schema: Some(SchemaValue::new(SchemaKind::String)), description: None, annotations: Default::default() }],
432 responses: vec![OperationResponse { status: "200".into(), description: None, media_types: vec![OperationMediaType { content_type: "application/json".into(), schema: Some(SchemaValue::new(SchemaKind::Object { fields: vec![Field { name: "next".into(), value: SchemaValue::new(SchemaKind::String), required: false, annotations: Default::default() }], additional_properties: AdditionalProperties::Forbidden })) }] }],
433 annotations: [("x-speakeasy-pagination".into(), json!({"type":"cursor", "inputs":[{"name":"cursor", "type":"cursor", "in":"parameters"}], "outputs":{"nextCursor":"$.next"}}))].into(),
434 ..Default::default()
435 }
436 }
437 #[test]
438 fn normalizes_extensions_and_explicit_rules_with_schema_validation() {
439 let mut operation = operation();
440 let api = Api::default();
441 let plan = normalize_pagination(&api, &operation, None)
442 .unwrap()
443 .unwrap();
444 assert_eq!(plan.inputs[0].location, "query");
445 assert_eq!(
446 plan.continuation.unwrap().select(&json!({"next":"abc"})),
447 Some(&json!("abc"))
448 );
449 let explicit: PaginationRule =
450 serde_json::from_value(json!({"type":"url", "outputs":{"nextUrl":"$.next"}})).unwrap();
451 assert!(
452 normalize_pagination(&api, &operation, Some(&explicit))
453 .unwrap()
454 .unwrap()
455 .same_origin
456 );
457 operation.parameters[0].schema = Some(SchemaValue::new(SchemaKind::Boolean));
458 assert!(normalize_pagination(&api, &operation, None).is_err());
459 }
460 #[test]
461 fn rejects_invalid_selectors_and_declarations_without_guessing() {
462 for path in [
463 "next",
464 "$..next",
465 "$.items[*]",
466 "$.items[foo]",
467 "/bad~2escape",
468 ] {
469 assert!(Selector::parse(path).is_err(), "{path}");
470 }
471 assert_eq!(
472 Selector::parse("$.pages[-1].next")
473 .unwrap()
474 .select(&json!({"pages":[{"next":"last"}]})),
475 Some(&json!("last"))
476 );
477 assert_eq!(
478 Selector::parse("/a~1b/0")
479 .unwrap()
480 .select(&json!({"a/b":[42]})),
481 Some(&json!(42))
482 );
483 let mut operation = operation();
484 operation.annotations.insert(
485 "x-poolster-pagination".into(),
486 json!({"type":"cursor", "inputs":[], "outputs":{"nextCursor":"$.next"}}),
487 );
488 assert!(normalize_pagination(&Api::default(), &operation, None).is_err());
489 operation.annotations.clear();
490 assert!(
491 normalize_pagination(&Api::default(), &operation, None)
492 .unwrap()
493 .is_none()
494 );
495 }
496 #[test]
497 fn pointer_array_indices_are_canonical_without_restricting_object_keys() {
498 let array = json!([10, 20]);
499 for name in ["01", "-1", "+1", ""] {
500 let selector = Selector::parse(&format!("/{name}")).unwrap();
501 assert!(selector.select(&array).is_none());
502 let schema = SchemaValue::new(SchemaKind::Array {
503 items: Box::new(SchemaValue::new(SchemaKind::Integer)),
504 });
505 assert!(selected_schema(&Api::default(), &schema, &selector).is_err());
506 }
507 assert_eq!(
508 Selector::parse("/1").unwrap().select(&array),
509 Some(&json!(20))
510 );
511 assert_eq!(
512 Selector::parse("$[-1]").unwrap().select(&array),
513 Some(&json!(20))
514 );
515 assert_eq!(
516 Selector::parse("/01").unwrap().select(&json!({"01":20})),
517 Some(&json!(20))
518 );
519 }
520
521 #[test]
522 fn validates_page_and_offset_parameters_and_result_arrays() {
523 let mut operation = operation();
524 operation.parameters = ["page", "limit"]
525 .into_iter()
526 .map(|name| OperationParameter {
527 name: name.into(),
528 location: "query".into(),
529 required: false,
530 schema: Some(SchemaValue::new(SchemaKind::Integer)),
531 description: None,
532 annotations: Default::default(),
533 })
534 .collect();
535 operation.responses.clear();
536 let rule: PaginationRule = serde_json::from_value(json!({"type":"page", "inputs":[{"name":"page", "type":"page"},{"name":"limit", "type":"limit"}], "outputs":{"results":"$.items"}})).unwrap();
537 let plan = normalize_pagination(&Api::default(), &operation, Some(&rule))
538 .unwrap()
539 .unwrap();
540 assert_eq!(plan.inputs.len(), 2);
541 operation.parameters[1].schema = Some(SchemaValue::new(SchemaKind::String));
542 assert!(normalize_pagination(&Api::default(), &operation, Some(&rule)).is_err());
543 }
544}