Skip to main content

poolster_core/
pagination.rs

1//! Validated pagination declarations shared by native generator plugins.
2use 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/// Same portable shape as x-poolster-pagination. An explicit rule overrides extensions.
17#[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    /// Renderers must constrain URL continuations to the configured API origin.
46    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    /// Supported portable paths: $.items, $.pages[-1].next and JSON pointers.
84    /// Wildcards, filters and script expressions are rejected rather than guessed.
85    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
164/// Validate an explicit recipe declaration or a retained Poolster/Speakeasy extension.
165/// Invalid declarations return a diagnostic instead of silently disabling pagination.
166pub 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}