Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions packages/app/api-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,5 @@
"typescript": "5.9.3",
"vitest": "^4.0.15",
"zod": "~4.1.13"
},
"dependencies": {
"@lokalise/universal-ts-utils": "^4.2.3"
Comment on lines -43 to -45
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer needed

}
}
56 changes: 45 additions & 11 deletions packages/app/api-common/src/apiSchemas.spec.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod/v4'
import {
multiCursorMandatoryPaginationSchema,
multiCursorOptionalPaginationSchema,
encodedCursorMandatoryPaginationSchema,
encodedCursorOptionalPaginationSchema,
paginatedResponseSchema,
} from './apiSchemas.ts'
import { encodeCursor } from './cursorCodec.ts'

describe('apiSchemas', () => {
describe('multi cursor pagination schemas', () => {
describe('encoded cursor pagination schemas', () => {
const uuid = '00000000-0000-0000-0000-000000000000'
const cursorSchema = z.object({
id: z.string().uuid(),
id: z.guid(),
name: z.string(),
})
type cursorType = z.infer<typeof cursorSchema>

describe('multiCursorMandatoryPaginationSchema', () => {
const schema = multiCursorMandatoryPaginationSchema(cursorSchema)
describe('encodedCursorMandatoryPaginationSchema', () => {
const schema = encodedCursorMandatoryPaginationSchema(cursorSchema)
type schemaType = z.infer<typeof schema>
type schemaTypeInput = z.input<typeof schema>

Expand All @@ -34,6 +34,19 @@
} satisfies schemaType)
})

it('should decode encoded number cursor and return correct type', () => {
const numberSchema = encodedCursorMandatoryPaginationSchema(z.number())
const result = numberSchema.parse({
limit: 10,
after: encodeCursor(300),
} satisfies z.input<typeof numberSchema>)

expect(result).toEqual({
limit: 10,
after: 300,
} satisfies z.infer<typeof numberSchema>)
})

it('should ignore undefined cursor', () => {
const object: schemaTypeInput = {
limit: 1,
Expand Down Expand Up @@ -61,7 +74,6 @@
})

it('wrong cursor type should produce error', () => {
const schema = multiCursorMandatoryPaginationSchema(cursorSchema)
const result = schema.safeParse({ limit: 10, after: {} })
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
Expand All @@ -80,7 +92,6 @@
})

it('wrong cursor string should produce error', () => {
const schema = multiCursorMandatoryPaginationSchema(cursorSchema)
const result = schema.safeParse({
limit: 10,
after: 'heyo',
Expand All @@ -101,7 +112,7 @@
})

it('wrong cursor object should produce error', () => {
const schema = multiCursorMandatoryPaginationSchema(cursorSchema)
const schema = encodedCursorMandatoryPaginationSchema(cursorSchema)
const result = schema.safeParse({
limit: 10,
after: encodeCursor({
Expand All @@ -111,7 +122,7 @@
})
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
expect(result.error).toMatchInlineSnapshot(`

Check failure on line 125 in packages/app/api-common/src/apiSchemas.spec.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

src/apiSchemas.spec.ts > apiSchemas > encoded cursor pagination schemas > encodedCursorMandatoryPaginationSchema > wrong cursor object should produce error

Error: Snapshot `apiSchemas > encoded cursor pagination schemas > encodedCursorMandatoryPaginationSchema > wrong cursor object should produce error 1` mismatched - Expected + Received [ZodError: [ { "origin": "string", "code": "invalid_format", - "format": "uuid", + "format": "guid", - "pattern": "/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/", + "pattern": "/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/", "path": [ "after", "id" ], - "message": "Invalid UUID" + "message": "Invalid GUID" } ]] ❯ src/apiSchemas.spec.ts:125:30
[ZodError: [
{
"origin": "string",
Expand All @@ -127,10 +138,33 @@
]]
`)
})

it('should fail if encoded number does not match schema', () => {
const positiveNumberSchema = encodedCursorMandatoryPaginationSchema(z.number().positive())
const result = positiveNumberSchema.safeParse({
limit: 10,
after: encodeCursor(-5),
})
expect(result.success).toBe(false)
expect(result.error).toMatchInlineSnapshot(`
[ZodError: [
{
"origin": "number",
"code": "too_small",
"minimum": 0,
"inclusive": false,
"path": [
"after"
],
"message": "Too small: expected number to be >0"
}
]]
`)
})
})

describe('multiCursorOptionalPaginationSchema', () => {
const schema = multiCursorOptionalPaginationSchema(cursorSchema)
describe('encodedCursorOptionalPaginationSchema', () => {
const schema = encodedCursorOptionalPaginationSchema(cursorSchema)
type schemaType = z.infer<typeof schema>
type schemaTypeInput = z.input<typeof schema>

Expand Down
17 changes: 9 additions & 8 deletions packages/app/api-common/src/apiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,19 @@ export type AfterPaginationParams = z.infer<typeof AFTER_PAGINATION_CONFIG_SCHEM
const decodeCursorHook = (value: string | undefined, ctx: RefinementCtx) => {
if (!value) return undefined

// Try to decode as base64 (for encoded numbers/objects)
const result = decodeCursor(value)
if (result.result) return result.result

ctx.addIssue({
message: 'Invalid cursor',
code: z.ZodIssueCode.custom,
params: { message: result.error.message },
code: 'custom',
params: { message: result.error?.message },
Comment on lines -30 to +32
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

z.ZodIssueCode is deprecated

})
}

export const multiCursorMandatoryPaginationSchema = <
CursorType extends z.ZodSchema<unknown, Record<string, unknown> | undefined>,
export const encodedCursorMandatoryPaginationSchema = <
CursorType extends z.ZodSchema<unknown, Record<string, unknown> | number | undefined>,
>(
cursorType: CursorType,
) => {
Expand All @@ -44,16 +45,16 @@ export const multiCursorMandatoryPaginationSchema = <
after: cursor,
})
}
export const multiCursorOptionalPaginationSchema = <
CursorType extends z.ZodSchema<unknown, Record<string, unknown> | undefined>,
export const encodedCursorOptionalPaginationSchema = <
CursorType extends z.ZodSchema<unknown, Record<string, unknown> | number | undefined>,
>(
cursorType: CursorType,
) => multiCursorMandatoryPaginationSchema(cursorType).partial({ limit: true })
) => encodedCursorMandatoryPaginationSchema(cursorType).partial({ limit: true })

export const zMeta = z.object({
count: z.number(),
cursor: z.string().optional().describe('Pagination cursor, a last item id from this result set'),
hasMore: z.boolean().optional().describe('Whether there are more items to fetch'),
Copy link
Copy Markdown
Collaborator Author

@CarlosGamero CarlosGamero Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only a couple of endpoints are using meta without hasMore, adding it there should be pretty simple.

hasMore: z.boolean().describe('Whether there are more items to fetch'),
})

export type PaginationMeta = z.infer<typeof zMeta>
Expand Down
7 changes: 6 additions & 1 deletion packages/app/api-common/src/cursorCodec.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {

describe('cursorCodec', () => {
describe('encode and decode', () => {
it('encode and decode works', () => {
it('encode and decode works for objects', () => {
const testValue = {
id: '1',
name: 'apple',
Expand All @@ -20,6 +20,11 @@ describe('cursorCodec', () => {
expect(decodeCursor(encodeCursor(testValue))).toEqual({ result: testValue })
})

it('encode and decode works for numbers', () => {
const testValue = 42
expect(decodeCursor(encodeCursor(testValue))).toEqual({ result: testValue })
})

it('trying to decode not encoded text', () => {
const result = decodeCursor('should fail')
expect(result.error).toBeDefined()
Expand Down
20 changes: 9 additions & 11 deletions packages/app/api-common/src/cursorCodec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { isObject } from '@lokalise/universal-ts-utils/type/isObject'

type Left<T> = {
error: T
result?: never
Expand All @@ -16,7 +14,7 @@ export type ConversionMode = 'buffer' | 'atob-btoa'
const resolveConversionMode = (): ConversionMode =>
typeof Buffer !== 'undefined' ? 'buffer' : 'atob-btoa'

export const base64urlToString = (base64url: string, mode: ConversionMode = 'buffer'): string => {
export const base64urlToString = (base64url: string, mode: ConversionMode): string => {
if (mode === 'buffer') {
return Buffer.from(base64url, 'base64url').toString('utf-8')
}
Expand All @@ -28,7 +26,7 @@ export const base64urlToString = (base64url: string, mode: ConversionMode = 'buf
return atob(paddedBase64)
}

export const stringToBase64url = (value: string, mode: ConversionMode = 'buffer'): string => {
export const stringToBase64url = (value: string, mode: ConversionMode): string => {
if (mode === 'buffer') {
return Buffer.from(value).toString('base64url')
}
Expand All @@ -43,22 +41,19 @@ export const stringToBase64url = (value: string, mode: ConversionMode = 'buffer'
* Encodes JSON object to base64url
* Compatible with both browser and node envs
*/
export const encodeCursor = (object: Record<string, unknown>): string => {
return stringToBase64url(JSON.stringify(object), resolveConversionMode())
}
export const encodeCursor = (cursor: Record<string, unknown> | number): string =>
stringToBase64url(JSON.stringify(cursor), resolveConversionMode())

/**
* Decodes base64url to JSON object
* Compatible with both browser and node envs
*/
export const decodeCursor = (value: string): Either<Error, Record<string, unknown>> => {
export const decodeCursor = (value: string): Either<Error, Record<string, unknown> | number> => {
let error: unknown
try {
const result: unknown = JSON.parse(base64urlToString(value, resolveConversionMode()))

if (result && isObject(result)) {
return { result }
}
if (result && isObjectOrNumber(result)) return { result }
} catch (e) {
error = e
}
Expand All @@ -67,3 +62,6 @@ export const decodeCursor = (value: string): Either<Error, Record<string, unknow
return { error: error instanceof Error ? error : new Error('Invalid cursor') }
/* v8 ignore stop */
}

const isObjectOrNumber = (value: unknown): value is Record<string, unknown> | number =>
typeof value === 'object' || typeof value === 'number'
105 changes: 17 additions & 88 deletions packages/app/api-common/src/paginationUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { OptionalPaginationParams } from './apiSchemas.ts'
import { encodeCursor } from './cursorCodec.ts'
import {
createPaginatedResponse,
getMetaForNextPage,
getPaginatedEntries,
getPaginatedEntriesByHasMore,
} from './paginationUtils.ts'
import { createPaginatedResponse, getPaginatedEntriesByHasMore } from './paginationUtils.ts'

describe('paginationUtils', () => {
describe('createPaginatedResponse', () => {
Expand All @@ -22,14 +17,6 @@ describe('paginationUtils', () => {
describe('pageLimit', () => {
const mockedArray = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]

it('pageLimit is undefined', () => {
const result = createPaginatedResponse(mockedArray, undefined)
expect(result).toEqual({
data: mockedArray,
meta: { count: 4, cursor: 'd', hasMore: undefined },
})
})

it('pageLimit less than input array', () => {
const result = createPaginatedResponse(mockedArray, 2)
expect(result).toEqual({
Expand Down Expand Up @@ -57,7 +44,10 @@ describe('paginationUtils', () => {

describe('cursor', () => {
it('empty cursorKeys produce error', () => {
expect(() => getMetaForNextPage([], [])).toThrowError('cursorKeys cannot be an empty array')
const mockedArray = [{ id: 'a' }]
expect(() => createPaginatedResponse(mockedArray, 1, [])).toThrowError(
'cursorKeys cannot be an empty array',
)
})

it('cursor using id as default', () => {
Expand Down Expand Up @@ -110,87 +100,26 @@ describe('paginationUtils', () => {
},
})
})
})
})

describe('getPaginatedEntries', () => {
it('should call api 2 times', async () => {
const spy = vi
.spyOn(market, 'getApples')
.mockResolvedValueOnce({
data: [{ id: 'red' }],
meta: {
count: 1,
cursor: 'red',
hasMore: false,
},
})
.mockResolvedValueOnce({
data: [],
meta: {
count: 0,
hasMore: false,
},
})

const result = await getPaginatedEntries({ limit: 1 }, (params) => {
return market.getApples(params)
})

expect(spy).toHaveBeenCalledTimes(2)
expect(result).toEqual([{ id: 'red' }])
})
it('should call api 1 time', async () => {
const spy = vi.spyOn(market, 'getApples').mockResolvedValueOnce({
data: [],
meta: {
count: 0,
hasMore: false,
},
})

const result = await getPaginatedEntries({ limit: 1 }, (params) => {
return market.getApples(params)
})

expect(spy).toHaveBeenCalledTimes(1)
expect(result).toEqual([])
})
it('should call api 3 time', async () => {
const spy = vi
.spyOn(market, 'getApples')
.mockResolvedValueOnce({
data: [{ id: 'red' }],
meta: {
count: 1,
cursor: 'red',
hasMore: false,
},
})
.mockResolvedValueOnce({
data: [{ id: 'blue' }],
meta: {
count: 1,
cursor: 'blue',
hasMore: false,
},
})
.mockResolvedValueOnce({
data: [],
it('cursor using single number prop is encoded', () => {
const mockedArray = [
{ id: '1', sequenceNumber: 100 },
{ id: '2', sequenceNumber: 200 },
{ id: '3', sequenceNumber: 300 },
]
const result = createPaginatedResponse(mockedArray, 3, ['sequenceNumber'])
expect(result).toEqual({
data: mockedArray,
meta: {
count: 0,
count: 3,
cursor: encodeCursor(300), // Number is encoded
hasMore: false,
},
})

const result = await getPaginatedEntries({ limit: 1 }, (params) => {
return market.getApples(params)
})

expect(spy).toHaveBeenCalledTimes(3)
expect(result).toEqual([{ id: 'red' }, { id: 'blue' }])
})
})

describe('getPaginatedEntriesByHasMore', () => {
it('should call api 1 time and return value', async () => {
const spy = vi.spyOn(market, 'getApples').mockResolvedValueOnce({
Expand Down
Loading
Loading