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(marketplace): add optional sorting, filtering, pagination to marketplace api #332

Merged
Changes from 6 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
7 changes: 7 additions & 0 deletions workspaces/marketplace/.changeset/quiet-kiwis-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@red-hat-developer-hub/backstage-plugin-marketplace-backend': patch
'@red-hat-developer-hub/backstage-plugin-marketplace-common': patch
'@red-hat-developer-hub/backstage-plugin-marketplace': patch
---

add sorting , filtering , pagination in /plugins api
Original file line number Diff line number Diff line change
@@ -23,6 +23,7 @@ import {
decodeQueryParams,
EntityFacetSchema,
GetEntityFacetsRequest,
GetPluginsRequest,
MarketplaceApi,
MarketplaceKinds,
} from '@red-hat-developer-hub/backstage-plugin-marketplace-common';
@@ -36,9 +37,16 @@ export async function createRouter({
const router = Router();
router.use(express.json());

router.get('/plugins', async (_req, res) => {
const plugins = await marketplaceApi.getPlugins();
res.json(plugins);
router.get('/plugins', async (req, res) => {
try {
const query = req.query as Partial<GetPluginsRequest>;
const plugins = await marketplaceApi.getPlugins(query);
res.json(plugins);
} catch (error) {
res
.status(500)
.json({ message: 'Failed to fetch plugins', error: error.message });
}
});

router.get('/plugins/:name', async (req, res) => {
Original file line number Diff line number Diff line change
@@ -80,7 +80,7 @@ describe('MarketplaceCatalogClient', () => {
it('should call queryEntities function', async () => {
const api = new MarketplaceCatalogClient(options);

await api.getPlugins();
await api.getPlugins({});

expect(mockQueryEntities).toHaveBeenCalledTimes(1);
expect(mockQueryEntities).toHaveBeenCalledWith(
@@ -93,13 +93,13 @@ describe('MarketplaceCatalogClient', () => {

it('should return the plugins', async () => {
const api = new MarketplaceCatalogClient(options);
const plugins = await api.getPlugins();
const plugins = await api.getPlugins({});
expect(plugins).toHaveLength(2);
});

it('should return the plugins when the auth options is not passed', async () => {
const api = new MarketplaceCatalogClient({ ...options, auth: undefined });
const plugins = await api.getPlugins();
const plugins = await api.getPlugins({});
expect(plugins).toHaveLength(2);
});
});
Original file line number Diff line number Diff line change
@@ -23,11 +23,14 @@ import {
} from '@backstage/catalog-client';
import { NotFoundError } from '@backstage/errors';
import {
GetPluginsRequest,
MarketplaceApi,
MarketplaceKinds,
MarketplacePlugin,
MarketplacePluginList,
MarketplacePluginWithPageInfo,
} from '../types';
import { convertGetPluginsRequestToQueryEntitiesRequest } from '../utils';

/**
* @public
@@ -59,18 +62,18 @@ export class MarketplaceCatalogClient implements MarketplaceApi {
});
}

async getPlugins(): Promise<MarketplacePlugin[]> {
async getPlugins(
query?: GetPluginsRequest,
): Promise<MarketplacePluginWithPageInfo> {
const token = await this.getServiceToken();
const result = await this.catalog.queryEntities(
{
filter: {
kind: 'plugin',
},
},
token,
);

return result.items as MarketplacePlugin[];
const payload = convertGetPluginsRequestToQueryEntitiesRequest(query);
const result = await this.catalog.queryEntities(payload, token);

return {
items: result.items as MarketplacePlugin[],
totalItems: result.totalItems,
pageInfo: result.pageInfo,
};
}

async getPluginLists(): Promise<MarketplacePluginList[]> {
Original file line number Diff line number Diff line change
@@ -23,3 +23,4 @@
export * from './types';
export * from './utils';
export * from './api';
export * from './utils';
55 changes: 55 additions & 0 deletions workspaces/marketplace/plugins/marketplace-common/src/types.ts
Original file line number Diff line number Diff line change
@@ -15,6 +15,8 @@
*/

import {
EntityFilterQuery,
EntityOrderQuery,
GetEntityFacetsRequest,
GetEntityFacetsResponse,
} from '@backstage/catalog-client';
@@ -28,6 +30,18 @@ export interface MarketplacePlugin extends Entity {
spec?: MarketplacePluginSpec;
}

/**
* @public
*/
export interface MarketplacePluginWithPageInfo {
items: MarketplacePlugin[];
totalItems?: Number;
pageInfo?: {
nextCursor?: string;
prevCursor?: string;
};
}

/**
* @public
*/
@@ -58,6 +72,14 @@ export enum MarketplaceKinds {
package = 'Package',
}

/**
* @public
*/
export enum SortOrder {
asc = 'asc',
desc = 'desc',
}

/**
* @public
*/
@@ -96,6 +118,39 @@ export interface MarketplacePluginSpec extends JsonObject {
};
}


/**
* @public
*/
export type FullTextFilter = {
term: string;
fields?: string[];
};

/**
* @public
*/
export type GetPluginsRequest = {
limit?: number;
offset?: number;
filter?: Record<string, string>;
orderFields?: EntityOrderQuery;
searchTerm?: string;
};

/**
* @public
*/
export interface MarketplaceApi {
getPlugins(
request?: GetPluginsRequest,
): Promise<MarketplacePluginWithPageInfo>;
getPluginByName(name: string): Promise<MarketplacePlugin>;
getPluginLists(): Promise<MarketplacePluginList[]>;
getPluginListByName(name: string): Promise<MarketplacePluginList>;
getPluginsByPluginListName(name: string): Promise<MarketplacePlugin[]>;
}

/**
* @public
*/
136 changes: 136 additions & 0 deletions workspaces/marketplace/plugins/marketplace-common/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
EntityFilterQuery,
EntityOrderQuery,
QueryEntitiesRequest,
} from '@backstage/catalog-client/index';
import { GetPluginsRequest, SortOrder } from './types';

const DefaultOrderFields: EntityOrderQuery = [
{ field: 'metadata.name', order: 'asc' },
];
const DefaultLimit = 20;
const RequiredFilter = { kind: 'plugin' };

Choose a reason for hiding this comment

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

Don't know if this is needed. But please start consts with lower case characters.

Copy link
Member Author

Choose a reason for hiding this comment

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

Have changed into lower case . Since we want to get plugins by default we need to pass { kind: 'plugin' } , in queryEntities . I wanted to get only 20 results by default .

Choose a reason for hiding this comment

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

Without a default limit it will return all entities? Let us not have a limit for now since the UI doesn't support pagination at the moment.


export const convertGetPluginsRequestToQueryEntitiesRequest = (
query?: GetPluginsRequest,
): QueryEntitiesRequest => {
let orderFields: EntityOrderQuery = DefaultOrderFields;

if (query?.orderFields) {
if (Array.isArray(query.orderFields)) {
orderFields = query.orderFields.map(field => {
if (typeof field === 'string') {
const [key, order] = (field as string).split(',');
return { field: key, order: order as SortOrder };
}
return field;
});
} else {
if (typeof query.orderFields === 'string') {
const [key, order] = (query.orderFields as string).split(',');
orderFields = { field: key, order: order as SortOrder };
}
}
}

const filter: EntityFilterQuery = query?.filter
? {
...(typeof query.filter === 'string'
? JSON.parse(query.filter)
: query.filter),
...RequiredFilter,
}
: RequiredFilter;
Copy link
Contributor

@karthikjeeyar karthikjeeyar Jan 31, 2025

Choose a reason for hiding this comment

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

You could reuse decodeFilterParams from /utils/decodeQueryParams.ts here.

const payload: QueryEntitiesRequest = {
filter: filter,
orderFields: orderFields,
limit: query?.limit ? Number(query.limit) : DefaultLimit,
offset: query?.offset ? Number(query.offset) : undefined,
};
if (query?.searchTerm) {
payload.fullTextFilter = { term: query.searchTerm };
}
return payload;
};

export const convertGetPluginRequestToSearchParams = (
query?: GetPluginsRequest,
): URLSearchParams => {
const params = new URLSearchParams();
if (query?.limit) params.append('limit', String(query.limit));
if (query?.offset) params.append('offset', String(query.offset));
if (query?.searchTerm) params.append('searchTerm', query.searchTerm);
if (query?.orderFields) {
if (Array.isArray(query.orderFields)) {
query.orderFields.forEach(({ field, order }) => {
params.append('orderFields', `${field},${order}`);
});
} else {
const { field, order } = query.orderFields;
params.append('orderFields', `${field},${order}`);
}
}
if (query?.filter) params.append('filter', JSON.stringify(query.filter));
return params;
};

export const convertSearchParamsToGetPluginsRequest = (
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it will be good to extend the generic encodeQueryParams function to handle more fields, Instead of having it's own version for GetPluginsRequest. This way we can reuse the function for other API like PluginLists, Packages etc.

params?: URLSearchParams,
): GetPluginsRequest => {
const request: GetPluginsRequest = {};

// Convert 'limit' parameter
const limit = params?.get('limit');
if (limit) {
request.limit = Number(limit);
}

// Convert 'offset' parameter
const offset = params?.get('offset');
if (offset) {
request.offset = Number(offset);
}

// Convert 'searchTerm' parameter
const searchTerm = params?.get('searchTerm');
if (searchTerm) {
request.searchTerm = searchTerm;
}

const orderFields = params?.getAll('orderFields');

if (Array.isArray(orderFields) && orderFields?.length > 0) {
request.orderFields = orderFields.map(field => {
const [key, order] = field.split(',');
return { field: key, order: order as SortOrder };
});
}

const filter: Record<string, string> = {};
params?.forEach((value, key) => {
if (key.startsWith('filter[') && key.endsWith(']')) {
const filterKey = key.slice(7, -1);
filter[filterKey] = value;
}
});

if (Object.keys(filter).length > 0) {
request.filter = filter;
}
return request;
};
Original file line number Diff line number Diff line change
@@ -17,12 +17,15 @@
import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';

import {
GetPluginsRequest,
encodeQueryParams,
GetEntityFacetsRequest,
GetEntityFacetsResponse,
MarketplaceApi,
MarketplacePlugin,
MarketplacePluginList,
MarketplacePluginWithPageInfo,
convertGetPluginRequestToSearchParams,
} from '@red-hat-developer-hub/backstage-plugin-marketplace-common';

export type MarketplaceBackendClientOptions = {
@@ -39,9 +42,12 @@ export class MarketplaceBackendClient implements MarketplaceApi {
this.fetchApi = options.fetchApi;
}

async getPlugins(): Promise<MarketplacePlugin[]> {
async getPlugins(
request?: GetPluginsRequest,
): Promise<MarketplacePluginWithPageInfo> {
const baseUrl = await this.discoveryApi.getBaseUrl('marketplace');
const url = `${baseUrl}/plugins`;
const params = convertGetPluginRequestToSearchParams(request);
const url = `${baseUrl}/plugins?${params.toString()}`;

const response = await this.fetchApi.fetch(url);
if (!response.ok) {
Original file line number Diff line number Diff line change
@@ -37,7 +37,7 @@ export const MarketplaceCatalogContent = () => {
>
<Typography variant="h5">
All plugins
{plugins.data ? ` (${plugins.data?.length})` : null}
{plugins.data ? ` (${plugins.data?.items?.length})` : null}
</Typography>
<SearchTextField variant="filter" />
</Stack>
Original file line number Diff line number Diff line change
@@ -171,10 +171,10 @@ export const MarketplaceCatalogGrid = () => {

const filteredEntries = React.useMemo(() => {
if (!search || !plugins.data) {
return plugins.data;
return plugins.data?.items;
}
const lowerCaseSearch = search.toLocaleLowerCase('en-US');
return plugins.data.filter(entry => {
return plugins.data.items.filter(entry => {
const lowerCaseValue = entry.metadata?.title?.toLocaleLowerCase('en-US');
return lowerCaseValue?.includes(lowerCaseSearch);
});
Original file line number Diff line number Diff line change
@@ -183,7 +183,7 @@ const EntryContent = ({ entry }: { entry: MarketplacePlugin }) => {

const Entry = ({ entryName }: { entryName: string }) => {
const plugins = usePlugins();
const entry = plugins.data?.find(e => e.metadata.name === entryName);
const entry = plugins.data?.items?.find(e => e.metadata.name === entryName);

if (plugins.isLoading) {
return <EntryContentSkeleton />;
Loading