mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
feat: add actions to FeedSource interface
Add listActions() and executeAction() to FeedSource for write operations back to external services. Actions use arktype schemas for input validation via StandardSchemaV1. - ActionDefinition type with optional input schema - FeedEngine routes actions with existence and ID validation - Source IDs use reverse-domain format (aris.location, aris.tfl) - LocationSource: update-location action with schema validation - TflSource: set-lines-of-interest action with lineId validation - No-op implementations for sources without actions Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
@@ -142,7 +142,7 @@ export class TflApi {
|
||||
|
||||
// Schemas
|
||||
|
||||
const lineId = type(
|
||||
export const lineId = type(
|
||||
"'bakerloo' | 'central' | 'circle' | 'district' | 'hammersmith-city' | 'jubilee' | 'metropolitan' | 'northern' | 'piccadilly' | 'victoria' | 'waterloo-city' | 'lioness' | 'mildmay' | 'windrush' | 'weaver' | 'suffragette' | 'liberty' | 'elizabeth'",
|
||||
)
|
||||
|
||||
|
||||
@@ -94,12 +94,12 @@ describe("TflSource", () => {
|
||||
describe("interface", () => {
|
||||
test("has correct id", () => {
|
||||
const source = new TflSource({ client: api })
|
||||
expect(source.id).toBe("tfl")
|
||||
expect(source.id).toBe("aris.tfl")
|
||||
})
|
||||
|
||||
test("depends on location", () => {
|
||||
const source = new TflSource({ client: api })
|
||||
expect(source.dependencies).toEqual(["location"])
|
||||
expect(source.dependencies).toEqual(["aris.location"])
|
||||
})
|
||||
|
||||
test("implements fetchItems", () => {
|
||||
@@ -122,7 +122,12 @@ describe("TflSource", () => {
|
||||
severity: "minor-delays",
|
||||
description: "Delays",
|
||||
},
|
||||
{ lineId: "central", lineName: "Central", severity: "closure", description: "Closed" },
|
||||
{
|
||||
lineId: "central",
|
||||
lineName: "Central",
|
||||
severity: "closure",
|
||||
description: "Closed",
|
||||
},
|
||||
]
|
||||
return lines ? all.filter((s) => lines.includes(s.lineId)) : all
|
||||
},
|
||||
@@ -144,7 +149,10 @@ describe("TflSource", () => {
|
||||
})
|
||||
|
||||
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
|
||||
const source = new TflSource({ client: lineFilteringApi, lines: ["northern"] })
|
||||
const source = new TflSource({
|
||||
client: lineFilteringApi,
|
||||
lines: ["northern"],
|
||||
})
|
||||
const filtered = await source.fetchItems(createContext())
|
||||
expect(filtered.length).toBe(1)
|
||||
|
||||
@@ -164,7 +172,12 @@ describe("TflSource", () => {
|
||||
|
||||
test("feed items have correct base structure", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||
const location: Location = {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
const items = await source.fetchItems(createContext(location))
|
||||
|
||||
for (const item of items) {
|
||||
@@ -178,7 +191,12 @@ describe("TflSource", () => {
|
||||
|
||||
test("feed items have correct data structure", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||
const location: Location = {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
const items = await source.fetchItems(createContext(location))
|
||||
|
||||
for (const item of items) {
|
||||
@@ -230,7 +248,12 @@ describe("TflSource", () => {
|
||||
|
||||
test("closestStationDistance is number when location provided", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||
const location: Location = {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
const items = await source.fetchItems(createContext(location))
|
||||
|
||||
for (const item of items) {
|
||||
@@ -248,6 +271,62 @@ describe("TflSource", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("actions", () => {
|
||||
test("listActions returns set-lines-of-interest", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const actions = await source.listActions()
|
||||
|
||||
expect(actions["set-lines-of-interest"]).toBeDefined()
|
||||
expect(actions["set-lines-of-interest"]!.id).toBe("set-lines-of-interest")
|
||||
})
|
||||
|
||||
test("executeAction set-lines-of-interest updates lines", async () => {
|
||||
const lineFilteringApi: ITflApi = {
|
||||
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
||||
const all: TflLineStatus[] = [
|
||||
{
|
||||
lineId: "northern",
|
||||
lineName: "Northern",
|
||||
severity: "minor-delays",
|
||||
description: "Delays",
|
||||
},
|
||||
{
|
||||
lineId: "central",
|
||||
lineName: "Central",
|
||||
severity: "closure",
|
||||
description: "Closed",
|
||||
},
|
||||
]
|
||||
return lines ? all.filter((s) => lines.includes(s.lineId)) : all
|
||||
},
|
||||
async fetchStations(): Promise<StationLocation[]> {
|
||||
return []
|
||||
},
|
||||
}
|
||||
|
||||
const source = new TflSource({ client: lineFilteringApi })
|
||||
await source.executeAction("set-lines-of-interest", ["northern"])
|
||||
|
||||
const items = await source.fetchItems(createContext())
|
||||
expect(items.length).toBe(1)
|
||||
expect(items[0]!.data.line).toBe("northern")
|
||||
})
|
||||
|
||||
test("executeAction throws on invalid input", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
|
||||
await expect(
|
||||
source.executeAction("set-lines-of-interest", "not-an-array"),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("executeAction throws for unknown action", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
|
||||
await expect(source.executeAction("nonexistent", {})).rejects.toThrow("Unknown action")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("TfL Fixture Data Shape", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Context, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
|
||||
import { contextValue } from "@aris/core"
|
||||
import { UnknownActionError, contextValue } from "@aris/core"
|
||||
import { LocationKey } from "@aris/source-location"
|
||||
import { type } from "arktype"
|
||||
|
||||
import type {
|
||||
ITflApi,
|
||||
@@ -13,7 +14,9 @@ import type {
|
||||
TflSourceOptions,
|
||||
} from "./types.ts"
|
||||
|
||||
import { TflApi } from "./tfl-api.ts"
|
||||
import { TflApi, lineId } from "./tfl-api.ts"
|
||||
|
||||
const setLinesInput = lineId.array()
|
||||
|
||||
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
||||
closure: 1.0,
|
||||
@@ -63,8 +66,8 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||
"elizabeth",
|
||||
]
|
||||
|
||||
readonly id = "tfl"
|
||||
readonly dependencies = ["location"]
|
||||
readonly id = "aris.tfl"
|
||||
readonly dependencies = ["aris.location"]
|
||||
|
||||
private readonly client: ITflApi
|
||||
private lines: TflLineId[]
|
||||
@@ -77,6 +80,31 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||
this.lines = options.lines ?? [...TflSource.DEFAULT_LINES_OF_INTEREST]
|
||||
}
|
||||
|
||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||
return {
|
||||
"set-lines-of-interest": {
|
||||
id: "set-lines-of-interest",
|
||||
description: "Update the set of monitored TfL lines",
|
||||
input: setLinesInput,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async executeAction(actionId: string, params: unknown): Promise<void> {
|
||||
switch (actionId) {
|
||||
case "set-lines-of-interest": {
|
||||
const result = setLinesInput(params)
|
||||
if (result instanceof type.errors) {
|
||||
throw new Error(result.summary)
|
||||
}
|
||||
this.setLinesOfInterest(result)
|
||||
return
|
||||
}
|
||||
default:
|
||||
throw new UnknownActionError(actionId)
|
||||
}
|
||||
}
|
||||
|
||||
async fetchContext(): Promise<null> {
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user