Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion packages/endpoint-micropub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,6 @@ To customise the behaviour of this plug-in, add `@indiekit/endpoint-micropub` to
- Previously published posts (list): `/micropub?q=source`
- Source content: `/micropub?q=source&url=WEBSITE_URL`

List queries support `filter`, `limit` and `offset` and parameters. For example, `/micropub?q=source&filter=web&limit=10&offset=10`.
List queries support `filter`, `limit` and `offset` parameters. For example, `/micropub?q=category&filter=web&limit=10&offset=10`.

The list of published posts is paged with `after` and `before` rather than `offset`, and can be narrowed with `filter` (a case-insensitive match on a post’s name and content) and `category` (posts with that category). For example, `/micropub?q=source&filter=web&category=indieweb&limit=10`.
26 changes: 23 additions & 3 deletions packages/endpoint-micropub/lib/controllers/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { getMf2Properties, jf2ToMf2 } from "../mf2.js";
* @typedef {object} QueryParameters
* @property {string|string[]} [after] - Return items after this item ID
* @property {string|string[]} [before] - Return items before this item ID
* @property {string} [filter] - Value to filter items by
* @property {string} [category] - Only return posts with this category
* @property {string} [filter] - Value to filter items by (for posts, a case-insensitive match on name and content)
* @property {string} [limit] - Number of items to return
* @property {string} [offset] - Offset to start limit of items
* @property {string|string[]} [properties] - mf2 properties to select
Expand All @@ -29,7 +30,8 @@ export const queryController = async (request, response, next) => {
const config = getConfig(application, publication);
const limit = Number(request.query.limit) || 0;
const offset = Number(request.query.offset) || 0;
let { after, before, filter, properties, q, uid, url } = request.query;
let { after, before, category, filter, properties, q, uid, url } =
request.query;

if (!q) {
throw IndiekitError.badRequest(
Expand Down Expand Up @@ -87,7 +89,25 @@ export const queryController = async (request, response, next) => {
};

if (postsCollection) {
cursor = await getCursor(postsCollection, after, before, limit);
// `filter` matches the name or content, `category` a category.
// Both are cast to strings so that, whatever the query parser
// yields, nothing reaches the database as an operator.
const pattern =
filter && new RegExp(RegExp.escape(String(filter)), "i");
const postsFilter = {
...(category && { "properties.category": String(category) }),
...(pattern && {
$or: [
{ "properties.name": pattern },
{ "properties.content.text": pattern },
{ "properties.content": pattern },
],
}),
};

cursor = await getCursor(postsCollection, after, before, limit, {
filter: postsFilter,
});
}

const items = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { strict as assert } from "node:assert";
import { after, before, describe, it } from "node:test";

import { testDatabase } from "@indiekit-test/database";
import { mockAgent } from "@indiekit-test/mock-agent";
import { testServer } from "@indiekit-test/server";
import { testToken } from "@indiekit-test/token";
import supertest from "supertest";

await mockAgent("endpoint-micropub");
const { client, mongoServer, mongoUri } = await testDatabase();
const server = await testServer({
application: { mongodbUrl: mongoUri },
});
const request = supertest.agent(server);

const query = (parameters) =>
request
.get("/micropub")
.auth(testToken(), { type: "bearer" })
.set("accept", "application/json")
.query({ q: "source", ...parameters });

describe("endpoint-micropub GET /micropub?q=source&filter=*&category=*", () => {
before(async () => {
// Two notes with content and one with a name only; the test
// configuration has no article post type, so no post may carry both
for (const post of [
"h=entry&name=Cheese+sandwich&category=food",
"h=entry&content=Bicycle+ride+at+dawn&category=cycling",
"h=entry&content=Building+for+the+web&category=food",
]) {
const created = await request
.post("/micropub")
.auth(testToken(), { type: "bearer" })
.set("accept", "application/json")
.send(post);
assert.ok(created.status < 300, `seed failed: ${created.text}`);
}
});

it("Filters posts by text in the name or content", async () => {
const byName = await query({ filter: "cheese" });
const byContent = await query({ filter: "WEB" });

assert.equal(byName.body.items.length, 1);
assert.equal(byName.body.items[0].properties.name[0], "Cheese sandwich");
assert.equal(byContent.body.items.length, 1);
assert.match(
JSON.stringify(byContent.body.items[0].properties.content),
/web/,
);
});

it("Filters posts by category", async () => {
const result = await query({ category: "food" });

assert.equal(result.body.items.length, 2);
});

it("Combines filters and keeps paging", async () => {
const page = await query({ category: "food", limit: 1 });
const next = await query({
category: "food",
limit: 1,
after: page.body.paging.after,
});

assert.equal(page.body.items.length, 1);
assert.equal(next.body.items.length, 1);
assert.notEqual(
page.body.items[0].properties.url[0],
next.body.items[0].properties.url[0],
);
assert.equal(next.body.paging.after, undefined);
});

it("Returns nothing when no post matches", async () => {
const result = await query({ filter: "sandwich", category: "cycling" });

assert.deepEqual(result.body.items, []);
});

after(async () => {
await client.close();
await mongoServer.stop();
server.close((error) => process.exit(error ? 1 : 0));
});
});
Loading