0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

chore(core,test): enable advanced search params for GET /applications API (#4495)

* chore(core,test): enable advanced search params for GET /applications API

* refactor: add query by app type(s) filter

* refactor: reuse search for app types
This commit is contained in:
Darcy Ye 2023-09-17 11:48:56 +08:00 committed by GitHub
parent 0899fd8dda
commit 8366af442a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 163 additions and 38 deletions

View file

@ -1,9 +1,10 @@
import type { Application, CreateApplication } from '@logto/schemas';
import { ApplicationType, Applications } from '@logto/schemas';
import type { OmitAutoSetFields } from '@logto/shared';
import { convertToIdentifiers, conditionalSql } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik';
import { convertToIdentifiers, conditionalSql, conditionalArraySql } from '@logto/shared';
import type { CommonQueryMethods, SqlSqlToken } from 'slonik';
import { sql } from 'slonik';
import { snakeCase } from 'snake-case';
import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
@ -22,15 +23,51 @@ const buildApplicationConditions = (search: Search) => {
Applications.fields.id,
Applications.fields.name,
Applications.fields.description,
Applications.fields.type,
];
return conditionalSql(
hasSearch,
() => sql`and ${buildConditionsFromSearch(search, searchFields)}`
() =>
/**
* Avoid specifying the DB column type when calling the API (which is meaningless).
* Should specify the DB column type of enum type.
*/
sql`${buildConditionsFromSearch(search, searchFields, {
[Applications.fields.type]: snakeCase('ApplicationType'),
})}`
);
};
const buildConditionArray = (conditions: SqlSqlToken[]) => {
const filteredConditions = conditions.filter((condition) => condition.sql !== '');
return conditionalArraySql(
filteredConditions,
(filteredConditions) => sql`where ${sql.join(filteredConditions, sql` and `)}`
);
};
export const createApplicationQueries = (pool: CommonQueryMethods) => {
const countApplications = async (search: Search) => {
const { count } = await pool.one<{ count: string }>(sql`
select count(*)
from ${table}
${buildConditionArray([buildApplicationConditions(search)])}
`);
return { count: Number(count) };
};
const findApplications = async (search: Search, limit?: number, offset?: number) =>
pool.any<Application>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
${buildConditionArray([buildApplicationConditions(search)])}
order by ${fields.createdAt} desc
${conditionalSql(limit, (value) => sql`limit ${value}`)}
${conditionalSql(offset, (value) => sql`offset ${value}`)}
`);
const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table);
const findAllApplications = buildFindAllEntitiesWithPool(pool)(Applications, [
@ -78,9 +115,11 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
const { count } = await pool.one<{ count: string }>(sql`
select count(*)
from ${table}
where ${fields.type} = ${ApplicationType.MachineToMachine}
and ${fields.id} in (${sql.join(applicationIds, sql`, `)})
${buildApplicationConditions(search)}
${buildConditionArray([
sql`${fields.type} = ${ApplicationType.MachineToMachine}`,
sql`${fields.id} in (${sql.join(applicationIds, sql`, `)})`,
buildApplicationConditions(search),
])}
`);
return { count: Number(count) };
@ -99,9 +138,11 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
return pool.any<Application>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.type} = ${ApplicationType.MachineToMachine}
and ${fields.id} in (${sql.join(applicationIds, sql`, `)})
${buildApplicationConditions(search)}
${buildConditionArray([
sql`${fields.type} = ${ApplicationType.MachineToMachine}`,
sql`${fields.id} in (${sql.join(applicationIds, sql`, `)})`,
buildApplicationConditions(search),
])}
limit ${limit}
offset ${offset}
`);
@ -119,6 +160,8 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
};
return {
countApplications,
findApplications,
findTotalNumberOfApplications,
findAllApplications,
findApplicationById,

View file

@ -22,8 +22,8 @@ const tenantContext = new MockTenant(
undefined,
{
applications: {
findTotalNumberOfApplications: jest.fn(async () => ({ count: 10 })),
findAllApplications: jest.fn(async () => [mockApplication]),
countApplications: jest.fn(async () => ({ count: 10 })),
findApplications: jest.fn(async () => [mockApplication]),
findApplicationById,
deleteApplicationById,
insertApplication: jest.fn(

View file

@ -14,6 +14,7 @@ import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import { buildOidcClientMetadata } from '#src/oidc/utils.js';
import assertThat from '#src/utils/assert-that.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
@ -34,10 +35,10 @@ export default function applicationRoutes<T extends AuthedRouter>(
const {
deleteApplicationById,
findApplicationById,
findAllApplications,
insertApplication,
updateApplicationById,
findTotalNumberOfApplications,
countApplications,
findApplications,
} = queries.applications;
const { findApplicationsRolesByApplicationId, insertApplicationsRoles, deleteApplicationRole } =
queries.applicationsRoles;
@ -46,19 +47,25 @@ export default function applicationRoutes<T extends AuthedRouter>(
router.get(
'/applications',
koaPagination({ isOptional: true }),
koaGuard({ response: z.array(Applications.guard), status: 200 }),
koaGuard({
response: z.array(Applications.guard),
status: 200,
}),
async (ctx, next) => {
const { limit, offset, disabled: paginationDisabled } = ctx.pagination;
const { searchParams } = ctx.URL;
const search = parseSearchParamsForSearch(searchParams);
if (paginationDisabled) {
ctx.body = await findAllApplications();
ctx.body = await findApplications(search);
return next();
}
const [{ count }, applications] = await Promise.all([
findTotalNumberOfApplications(),
findAllApplications(limit, offset),
countApplications(search),
findApplications(search, limit, offset),
]);
// Return totalCount to pagination middleware

View file

@ -1,6 +1,6 @@
import { SearchJointMode, SearchMatchMode } from '@logto/schemas';
import type { Nullable, Optional } from '@silverhand/essentials';
import { yes, conditionalString } from '@silverhand/essentials';
import { yes, conditionalString, conditional } from '@silverhand/essentials';
import { sql } from 'slonik';
import { snakeCase } from 'snake-case';
@ -190,6 +190,43 @@ const getMatchModeOperator = (match: SearchMatchMode, isCaseSensitive: boolean)
}
};
const validateAndBuildValueExpression = (
rawValues: string[],
field: string,
shouldLowercase: boolean,
fieldsTypeMapping?: Record<string, string>
) => {
const values =
shouldLowercase && isLowercaseValid(field, fieldsTypeMapping)
? rawValues.map((rawValue) => rawValue.toLowerCase())
: rawValues;
// Type check for the first value
assertThat(
values[0] && values.every(Boolean),
new TypeError(`Empty value found${conditionalString(field && ` for field ${field}`)}.`)
);
const valueExpression =
values.length === 1
? sql`${values[0]}`
: sql`any(${sql.array(values, conditional(fieldsTypeMapping?.[field]) ?? 'varchar')})`;
return valueExpression;
};
const isLowercaseValid = (field: string, fieldsTypeMapping?: Record<string, string>) => {
return !conditional(fieldsTypeMapping?.[field]);
};
const showLowercase = (
shouldLowercase: boolean,
field: string,
fieldsTypeMapping?: Record<string, string>
) => {
return shouldLowercase && isLowercaseValid(field, fieldsTypeMapping);
};
/**
* Build search SQL token by parsing the search object and available search fields.
* Note all `field`s will be normalized to snake case, so camel case fields are valid.
@ -200,11 +237,15 @@ const getMatchModeOperator = (match: SearchMatchMode, isCaseSensitive: boolean)
* @returns The SQL token that includes the all condition checks.
* @throws TypeError error if fields in `search` do not match the `searchFields`, or invalid condition found (e.g. the value is empty).
*/
export const buildConditionsFromSearch = (search: Search, searchFields: string[]) => {
export const buildConditionsFromSearch = (
search: Search,
searchFields: string[],
fieldsTypeMapping?: Record<string, string>
) => {
assertThat(searchFields.length > 0, new TypeError('No search field found.'));
const { matches, joint, isCaseSensitive } = search;
const conditions = matches.map(({ mode, field: rawField, values: rawValues }) => {
const conditions = matches.map(({ mode, field: rawField, values }) => {
const field = rawField && snakeCase(rawField);
if (field && !searchFields.includes(field)) {
@ -215,23 +256,21 @@ export const buildConditionsFromSearch = (search: Search, searchFields: string[]
const shouldLowercase = !isCaseSensitive && mode === SearchMatchMode.Exact;
const fields = field ? [field] : searchFields;
const values = shouldLowercase ? rawValues.map((value) => value.toLowerCase()) : rawValues;
// Type check for the first value
assertThat(
values[0] && values.every(Boolean),
new TypeError(`Empty value found${conditionalString(field && ` for field ${field}`)}.`)
);
const valueExpression =
values.length === 1 ? sql`${values[0]}` : sql`any(${sql.array(values, 'varchar')})`;
const getValueExpressionFor = (fieldName: string, shouldLowercase: boolean) =>
validateAndBuildValueExpression(values, fieldName, shouldLowercase, fieldsTypeMapping);
return sql`(${sql.join(
fields.map(
(field) =>
sql`${
shouldLowercase ? sql`lower(${sql.identifier([field])})` : sql.identifier([field])
} ${getMatchModeOperator(mode, isCaseSensitive)} ${valueExpression}`
showLowercase(shouldLowercase, field, fieldsTypeMapping)
? sql`lower(${sql.identifier([field])})`
: sql.identifier([field])
} ${getMatchModeOperator(mode, isCaseSensitive)} ${getValueExpressionFor(
field,
shouldLowercase
)}`
),
sql` or `
)})`;

View file

@ -1,10 +1,11 @@
import type {
Application,
CreateApplication,
ApplicationType,
OidcClientMetadata,
Role,
import {
type Application,
type CreateApplication,
type ApplicationType,
type OidcClientMetadata,
type Role,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { authedAdminApi } from './api.js';
@ -23,7 +24,16 @@ export const createApplication = async (
})
.json<Application>();
export const getApplications = async () => authedAdminApi.get('applications').json<Application[]>();
export const getApplications = async (types?: ApplicationType[]) => {
const searchParams = new URLSearchParams(
conditional(
types &&
types.length > 0 && [...types.map((type) => ['search.type', type]), ['mode.type', 'exact']]
)
);
return authedAdminApi.get('applications', { searchParams }).json<Application[]>();
};
export const getApplication = async (applicationId: string) =>
authedAdminApi.get(`applications/${applicationId}`).json<Application & { isAdmin: boolean }>();

View file

@ -81,6 +81,32 @@ describe('admin console application', () => {
expect(applicationNames).toContain('test-update-app');
});
it('should create m2m application successfully and can get only m2m applications by specifying types', async () => {
await createApplication('test-m2m-app-1', ApplicationType.MachineToMachine);
await createApplication('test-m2m-app-2', ApplicationType.MachineToMachine);
const m2mApps = await getApplications([ApplicationType.MachineToMachine]);
const m2mAppNames = m2mApps.map(({ name }) => name);
expect(m2mAppNames).toContain('test-m2m-app-1');
expect(m2mAppNames).toContain('test-m2m-app-2');
});
it('total number of apps should equal to the sum of number of each types', async () => {
const allApps = await getApplications();
const m2mApps = await getApplications([ApplicationType.MachineToMachine]);
const spaApps = await getApplications([ApplicationType.SPA]);
const otherApps = await getApplications([ApplicationType.Native, ApplicationType.Traditional]);
expect(allApps.length).toBe(m2mApps.length + spaApps.length + otherApps.length);
const allAppNames = allApps.map(({ name }) => name);
const spaAppNames = spaApps.map(({ name }) => name);
const otherAppNames = otherApps.map(({ name }) => name);
expect(allAppNames).toContain('test-m2m-app-1');
expect(allAppNames).toContain('test-m2m-app-2');
expect(spaAppNames).not.toContain('test-m2m-app-1');
expect(spaAppNames).not.toContain('test-m2m-app-2');
expect(otherAppNames).not.toContain('test-m2m-app-1');
expect(otherAppNames).not.toContain('test-m2m-app-2');
});
it('should delete application successfully', async () => {
const application = await createApplication('test-delete-app', ApplicationType.Native);