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:
parent
0899fd8dda
commit
8366af442a
6 changed files with 163 additions and 38 deletions
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 `
|
||||
)})`;
|
||||
|
|
|
@ -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 }>();
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
Loading…
Reference in a new issue