Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow detailed filtering of pubvalues and updatedAt/createdAt through the api #985

Draft
wants to merge 28 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2cb8148
feat: add generic filtering capabalities to getPubsWithRelatedValues
tefkah Feb 24, 2025
e1c6566
docs: add a little bit of documentation
tefkah Feb 24, 2025
9d23663
fix: properly pass through filters to api
tefkah Feb 24, 2025
e375ed3
fix: add basic test for api filtering
tefkah Feb 24, 2025
4060019
fix: updatedAt/createdAt tests
tefkah Feb 24, 2025
3dec08a
Merge branch 'main' into tfk/pub-filter
tefkah Feb 25, 2025
2b2916d
fix: remove passthrough
tefkah Feb 25, 2025
ffb0324
docs: add hardcoded docs for the filters rather than fixing zod-openapi
tefkah Feb 25, 2025
59bde03
Merge branch 'main' into tfk/pub-filter
tefkah Feb 25, 2025
54c141b
Merge branch 'main' into tfk/pub-filter
tefkah Feb 25, 2025
295aef2
Merge branch 'main' into tfk/pub-filter
tefkah Feb 26, 2025
a23ee8e
fix: support more natural query syntax
tefkah Feb 26, 2025
0935bb5
dev: add integration test for parsing behavior
tefkah Feb 26, 2025
55b9d24
dev: add explicit jsonquery test with client
tefkah Feb 26, 2025
7429d81
fix: fix type error
tefkah Feb 26, 2025
5a91c8b
fix: allow booleans
tefkah Feb 26, 2025
a6e99ae
fix: remove passthrough
tefkah Feb 26, 2025
59e33f9
fix: add a ton of tests for filter parsing, and make it work as expected
tefkah Feb 26, 2025
44eebdb
fix: actually make logical filters work correctly
tefkah Feb 26, 2025
5d01997
feat: add little typesafe mapping lib
tefkah Feb 27, 2025
eedd289
chore: remove comment
tefkah Feb 27, 2025
5a2f497
fix: make typing of pub-filters better
tefkah Feb 27, 2025
64329ac
dev: rework the tests, expose not working filtering (despair)
tefkah Feb 27, 2025
0713300
Merge branch 'main' into tfk/pub-filter
tefkah Mar 3, 2025
956fef8
Merge branch 'tfk/pub-filter' of https://github.com/pubpub/platform i…
tefkah Mar 3, 2025
5300d20
fix: IT WORKS
tefkah Mar 3, 2025
d42f02b
Merge branch 'main' into tfk/pub-filter
tefkah Mar 4, 2025
4270bff
Merge branch 'main' into tfk/pub-filter
tefkah Mar 4, 2025
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
102 changes: 97 additions & 5 deletions core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { User } from "lucia";

import { headers } from "next/headers";
import { createNextHandler } from "@ts-rest/serverless/next";
import { createNextHandler, RequestValidationError } from "@ts-rest/serverless/next";
import { jsonObjectFrom } from "kysely/helpers/postgres";
import qs from "qs";
import { z } from "zod";

import type {
Expand All @@ -19,8 +20,9 @@ import type {
ApiAccessPermissionConstraintsInput,
LastModifiedBy,
} from "db/types";
import { siteApi } from "contracts";
import { baseFilterSchema, filterSchema, siteApi } from "contracts";
import { ApiAccessScope, ApiAccessType, Capabilities, MembershipType } from "db/public";
import { assert } from "utils";

import type { CapabilityTarget } from "~/lib/authorization/capabilities";
import { db } from "~/kysely/database";
Expand All @@ -29,6 +31,7 @@ import { userCan } from "~/lib/authorization/capabilities";
import { getStage } from "~/lib/db/queries";
import { createLastModifiedBy } from "~/lib/lastModifiedBy";
import {
BadRequestError,
createPubRecursiveNew,
deletePub,
doesPubExist,
Expand All @@ -50,6 +53,7 @@ import { findCommunityBySlug } from "~/lib/server/community";
import { getPubType, getPubTypesForCommunity } from "~/lib/server/pubtype";
import { getStages } from "~/lib/server/stages";
import { getMember, getSuggestedUsers, SAFE_USER_SELECT } from "~/lib/server/user";
import { validateFilter } from "~/lib/server/validate-filters";

const baseAuthorizationObject = Object.fromEntries(
Object.keys(ApiAccessScope).map(
Expand Down Expand Up @@ -226,6 +230,81 @@ const shouldReturnRepresentation = async () => {
return false;
};

/**
* manually parses the `?filters` query param.
* necessary because ts-rest only supports parsing object in query params
* if they're uri encoded.
*
* eg this does not fly
* ```
* ?filters[community-slug:fieldName][$eq]=value
* ```
* but this does
* ```
* ?filters=%7B%22%7B%22updatedAt%22%3A%20%7B%22%24gte%22%3A%20%222025-01-01%22%7D%2C%22field-slug%22%3A%20%7B%22%24eq%22%3A%20%22some-value%22%7D%7D`
* ```
*
* the latter is what a ts-rest client sends if `json-query: true`. we want to support both syntaxes.
*
*/
const manuallyParsePubFilterQueryParams = (url: string, query?: Record<string, any>) => {
if (!query || Object.keys(query).length === 0) {
return query;
}

// check if we already have properly structured filters
if (query.filters && typeof query.filters === "object") {
try {
const validatedFilters = filterSchema.parse(query.filters);
return {
...query,
filters: validatedFilters,
};
} catch (e) {
throw new RequestValidationError(null, null, e, null);
}
}

// check if we have filter-like keys (using bracket notation)
const filterLikeKeys = Object.keys(query).filter((key) => key.startsWith("filters["));

if (filterLikeKeys.length === 0) {
return query;
}

const queryString = url.split("?")[1];
if (!queryString) {
return query;
}

try {
// parse with qs
const parsedQuery = qs.parse(queryString, {
depth: 10,
arrayLimit: 100,
allowDots: false, // don't convert dots to objects (use brackets only)
ignoreQueryPrefix: true, // remove the leading '?' if present
});

if (!parsedQuery.filters) {
return query; // no filters found after parsing
}

const validatedFilters = filterSchema.parse(parsedQuery.filters);

return {
...query,
...parsedQuery,
filters: validatedFilters,
};
} catch (e) {
if (e instanceof z.ZodError) {
throw new RequestValidationError(null, null, e, null);
}
throw new BadRequestError(`Error parsing filters: ${e.message}`);
}
};

const handler = createNextHandler(
siteApi,
{
Expand Down Expand Up @@ -267,14 +346,24 @@ const handler = createNextHandler(
body: pub,
};
},
getMany: async ({ query }) => {
getMany: async ({ query }, { request }) => {
const { user, community } = await checkAuthorization({
token: { scope: ApiAccessScope.pub, type: ApiAccessType.read },
// TODO: figure out capability here
cookies: false,
});

const { pubTypeId, stageId, ...rest } = query;
const { pubTypeId, stageId, filters, ...rest } = query ?? {};

const manuallyParsedFilters = manuallyParsePubFilterQueryParams(request.url, query);

if (manuallyParsedFilters?.filters) {
try {
await validateFilter(community.id, manuallyParsedFilters.filters);
} catch (e) {
throw new BadRequestError(e.message);
}
}

const pubs = await getPubsWithRelatedValuesAndChildren(
{
Expand All @@ -283,7 +372,10 @@ const handler = createNextHandler(
stageId,
userId: user.id,
},
rest
{
...rest,
filters: manuallyParsedFilters?.filters,
}
);

return {
Expand Down
8 changes: 6 additions & 2 deletions core/lib/__tests__/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import type { db } from "~/kysely/database";

const deepSortValues = (pub: ProcessedPub): ProcessedPub => {
pub.values
.sort((a, b) => (a.value as string).localeCompare(b.value as string))
.sort((a, b) => {
const aValue = String(a.value);
const bValue = String(b.value);
return aValue.localeCompare(bValue);
})
.map((item) => ({
...item,
relatedPub: item.relatedPub?.values ? deepSortValues(item.relatedPub) : item.relatedPub,
Expand Down Expand Up @@ -60,7 +64,7 @@ expect.extend({
message: () =>
pass
? `Expected pub ${isNot ? "not" : ""} to have values ${JSON.stringify(expected)}, and it does ${isNot ? "not" : ""}`
: `Expected pub ${isNot ? "not to" : "to"} match values ${this.utils.diff(sortedPubValues.values, expected)}`,
: `Expected pub ${isNot ? "not to" : "to"} match values ${this.utils.diff(expected, sortedPubValues.values)}`,
};
},
});
71 changes: 71 additions & 0 deletions core/lib/mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Prettify, TupleToCustomObject } from "./types";

/**
* Type-safe version of Object.entries()
* Returns an array of tuples containing key-value pairs with proper typing
* Includes all properties that exist in the object, even if undefined
*/
export function entries<
const T extends Record<string, unknown>,
const KeepUndefined extends boolean = false,
>(
obj: T,
keepUndefined?: KeepUndefined
): T extends T
? { [K in keyof T]-?: [K, KeepUndefined extends true ? T[K] : NonNullable<T[K]>] }[keyof T][]
: never {
const newObj = Object.entries(obj);

if (keepUndefined) {
return newObj as any;
}

return newObj.filter(([_, v]) => v !== undefined) as any;
}

/**
* Type-safe version of Object.fromEntries()
* Creates an object from an array of key-value pairs with proper typing
*/
export function fromEntries<const T extends [PropertyKey, any][]>(
entries: T
): { [K in T[number][0]]: Extract<T[number], [K, any]>[1] } {
return Object.fromEntries(entries) as any;
}

type MapToEntries<
T extends readonly unknown[],
M extends readonly string[],
C extends any[] = [],
Buffer extends Record<string, any> = {},
> = C["length"] extends T["length"]
? Buffer
: MapToEntries<
T,
M,
[...C, C["length"]],
Prettify<
Buffer & {
[NewKey in M[C["length"]]]: T[C["length"]];
}
>
>;

export function mapToEntries<
const T extends readonly unknown[],
const M extends readonly string[],
C extends any[] = [],
Buffer extends Record<string, any> = {},
>(obj: T, mapping: M): T extends T ? MapToEntries<T, M, C, Buffer> : never {
const result: Record<string, any> = {};
for (let i = 0; i < Math.min(obj.length, mapping.length); i++) {
result[mapping[i]] = obj[i];
}
return result as any;
}

export function keys<const T extends Record<string, any>>(
obj: T
): T extends T ? (keyof T)[] : never {
return Object.keys(obj) as any;
}
Loading
Loading