mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 23:21:30 -07:00
[playersettings] fix delete setting
This commit is contained in:
@@ -10,11 +10,18 @@ Player-settings worker served on the `playersettings` subdomain.
|
|||||||
`key=…&value=…` (or a JSON `{key,value}` / array) and **upserts** it into the
|
`key=…&value=…` (or a JSON `{key,value}` / array) and **upserts** it into the
|
||||||
player's settings, keyed by the `sub` claim of the Bearer JWT. Returns `200`.
|
player's settings, keyed by the `sub` claim of the Bearer JWT. Returns `200`.
|
||||||
Persisted in Workers KV (`RECFLARE_PLAYER_SETTINGS`, key `player:<id>`).
|
Persisted in Workers KV (`RECFLARE_PLAYER_SETTINGS`, key `player:<id>`).
|
||||||
|
- `DELETE /playersettings` — `[Authorize]`. Removes a setting from the player's
|
||||||
|
map. The client sends a bare form-urlencoded `key=PlayerShoppingBagId` (no
|
||||||
|
`value`); a JSON body and a `?key=` query param are also read. Deleting a key
|
||||||
|
that isn't stored is a no-op `200`, not a `404`.
|
||||||
|
|
||||||
> A full settings PUT would replace the player's _entire_ settings set on each
|
> A full settings PUT would replace the player's _entire_ settings set on each
|
||||||
> call; we merge instead, so a single-key PUT (e.g. `key=PlayerSessionCount`)
|
> call; we merge instead, so a single-key PUT (e.g. `key=PlayerSessionCount`)
|
||||||
> doesn't wipe the others.
|
> doesn't wipe the others.
|
||||||
|
|
||||||
|
> Emptying the map with DELETE puts the player back to a first read: the next
|
||||||
|
> `GET` re-seeds the defaults.
|
||||||
|
|
||||||
## KV namespace
|
## KV namespace
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -103,3 +103,23 @@ export const SettingJsonWrite = z.union([
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form-encoded delete the client actually sends: a bare `key=PlayerShoppingBagId`,
|
||||||
|
* with no `value`. An empty `key` is ignored.
|
||||||
|
*/
|
||||||
|
export const SettingFormDelete = z.object({
|
||||||
|
key: z.string().describe('The setting name to remove; an empty key is ignored'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON form of the same delete. Accepted as a bare setting name, a `{ key }` /
|
||||||
|
* `{ Key }` object, or an array of either.
|
||||||
|
*/
|
||||||
|
export const SettingJsonDelete = z.union([
|
||||||
|
z.string(),
|
||||||
|
z.object({ key: z.string().optional(), Key: z.string().optional() }),
|
||||||
|
z.array(
|
||||||
|
z.union([z.string(), z.object({ key: z.string().optional(), Key: z.string().optional() })])
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
HealthResponse,
|
HealthResponse,
|
||||||
json,
|
json,
|
||||||
PlayerSettingEntry,
|
PlayerSettingEntry,
|
||||||
|
SettingFormDelete,
|
||||||
SettingFormWrite,
|
SettingFormWrite,
|
||||||
|
SettingJsonDelete,
|
||||||
SettingJsonWrite,
|
SettingJsonWrite,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
@@ -26,7 +28,7 @@ import type { App } from './context'
|
|||||||
* (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
|
* (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
|
||||||
* their first read.
|
* their first read.
|
||||||
*
|
*
|
||||||
* Both routes are auth-gated on the Bearer JWT issued by the `auth` worker.
|
* Every `/playersettings` route is auth-gated on the Bearer JWT issued by the `auth` worker.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +80,45 @@ async function parseSettings(c: Context<App>): Promise<Array<{ key: string; valu
|
|||||||
return key ? [{ key, value }] : []
|
return key ? [{ key, value }] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the setting name(s) to remove out of a DELETE body. The client sends a bare
|
||||||
|
* form-urlencoded `key=PlayerShoppingBagId` — with no `value`, and (unlike its PUTs) not
|
||||||
|
* always a `content-type` Hono's body parser recognises on a DELETE, so an unparsed body
|
||||||
|
* is re-read as raw text. A JSON body is accepted too, as a bare string, a `{ key }`
|
||||||
|
* object, or an array of either. Blank names are dropped.
|
||||||
|
*/
|
||||||
|
async function parseDeleteKeys(c: Context<App>): Promise<string[]> {
|
||||||
|
const contentType = c.req.header('content-type') ?? ''
|
||||||
|
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
const body = await c.req.json<unknown>().catch(() => null)
|
||||||
|
const list = Array.isArray(body) ? body : body == null ? [] : [body]
|
||||||
|
return list
|
||||||
|
.map((o) => {
|
||||||
|
if (typeof o === 'string') return o
|
||||||
|
const rec = o as Record<string, unknown>
|
||||||
|
const key = rec.key ?? rec.Key
|
||||||
|
return typeof key === 'string' ? key : ''
|
||||||
|
})
|
||||||
|
.filter((k) => k !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick ONE read of the body from the content-type: Hono's parser only recognises the
|
||||||
|
// form types, and re-reading as text after it has cached a FormData re-serialises the
|
||||||
|
// body as multipart, so trying both in turn parses garbage.
|
||||||
|
let key = ''
|
||||||
|
if (contentType.includes('form-data') || contentType.includes('x-www-form-urlencoded')) {
|
||||||
|
const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||||
|
if (typeof form.key === 'string') key = form.key
|
||||||
|
} else {
|
||||||
|
key = new URLSearchParams(await c.req.text().catch(() => '')).get('key') ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort, for a client that hangs the name off the URL instead.
|
||||||
|
if (key === '') key = c.req.query('key') ?? ''
|
||||||
|
return key ? [key] : []
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -180,6 +221,59 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Remove a setting from the caller's map. The client sends `key=PlayerShoppingBagId`
|
||||||
|
// when it drops a value it no longer wants defaulted (a stale shopping bag id, say)
|
||||||
|
// rather than writing an empty string over it.
|
||||||
|
.delete(
|
||||||
|
'/playersettings',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Player Settings'],
|
||||||
|
summary: 'Delete a player setting',
|
||||||
|
description: [
|
||||||
|
'Removes the named setting(s) from the caller’s KV map. The client sends a bare',
|
||||||
|
'form-urlencoded `key=PlayerShoppingBagId` (no `value`); a JSON body — a string, a',
|
||||||
|
'`{ key }` object, or an array of either — and a `?key=` query param are also read.',
|
||||||
|
'Deleting a key that isn’t stored, or sending nothing to delete, is a no-op 200, not a',
|
||||||
|
'404. Empty body on success.',
|
||||||
|
'',
|
||||||
|
'Note that emptying the map entirely puts the player back to a first read: the next',
|
||||||
|
'`GET` re-seeds the defaults.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
requestBody: formOrJson(SettingFormDelete, SettingJsonDelete, 'The setting(s) to remove'),
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Removed, or nothing to remove (empty body)' },
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const keys = await parseDeleteKeys(c)
|
||||||
|
if (keys.length === 0) return c.body(null, 200)
|
||||||
|
|
||||||
|
const kvKey = `player:${id}`
|
||||||
|
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||||
|
kvKey,
|
||||||
|
'json'
|
||||||
|
)
|
||||||
|
if (!existing) return c.body(null, 200)
|
||||||
|
|
||||||
|
const remaining = { ...existing }
|
||||||
|
let removed = false
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key in remaining) {
|
||||||
|
delete remaining[key]
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed) await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(remaining))
|
||||||
|
|
||||||
|
return c.body(null, 200)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The generated spec. Documentation only — no request is validated against it (see
|
// The generated spec. Documentation only — no request is validated against it (see
|
||||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||||
app.get(
|
app.get(
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ function putForm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteForm(
|
||||||
|
fields: Record<string, string>,
|
||||||
|
headers: Record<string, string> = {}
|
||||||
|
): RequestInit {
|
||||||
|
return {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||||
|
body: new URLSearchParams(fields).toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('playersettings endpoints', () => {
|
describe('playersettings endpoints', () => {
|
||||||
it('GET / reports service status', async () => {
|
it('GET / reports service status', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/`)
|
const res = await SELF.fetch(`${ORIGIN}/`)
|
||||||
@@ -134,6 +145,80 @@ describe('playersettings endpoints', () => {
|
|||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('DELETE /playersettings 401s without a token', async () => {
|
||||||
|
const res = await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
deleteForm({ key: 'PlayerShoppingBagId' })
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELETE /playersettings removes the named key and leaves the rest', async () => {
|
||||||
|
await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
putForm({ key: 'PlayerShoppingBagId', value: 'bag-1' }, await bearer('20'))
|
||||||
|
)
|
||||||
|
await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
putForm({ key: 'PlayerSessionCount', value: '3' }, await bearer('20'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
deleteForm({ key: 'PlayerShoppingBagId' }, await bearer('20'))
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
|
||||||
|
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||||
|
'player:20',
|
||||||
|
'json'
|
||||||
|
)
|
||||||
|
expect(stored).toEqual({ PlayerSessionCount: '3' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELETE /playersettings reads a body with no content-type', async () => {
|
||||||
|
await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
putForm({ key: 'PlayerShoppingBagId', value: 'bag-2' }, await bearer('21'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/playersettings`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: await bearer('21'),
|
||||||
|
body: 'key=PlayerShoppingBagId',
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
|
||||||
|
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||||
|
'player:21',
|
||||||
|
'json'
|
||||||
|
)
|
||||||
|
expect(stored).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELETE /playersettings 200s for an unknown key and an empty body', async () => {
|
||||||
|
await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
putForm({ key: 'A', value: '1' }, await bearer('22'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const unknown = await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
deleteForm({ key: 'NotStored' }, await bearer('22'))
|
||||||
|
)
|
||||||
|
expect(unknown.status).toBe(200)
|
||||||
|
|
||||||
|
const empty = await SELF.fetch(`${ORIGIN}/playersettings`, deleteForm({}, await bearer('22')))
|
||||||
|
expect(empty.status).toBe(200)
|
||||||
|
|
||||||
|
// Neither call touched the stored map.
|
||||||
|
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||||
|
'player:22',
|
||||||
|
'json'
|
||||||
|
)
|
||||||
|
expect(stored).toEqual({ A: '1' })
|
||||||
|
})
|
||||||
|
|
||||||
it('GET /openapi.json documents every route', async () => {
|
it('GET /openapi.json documents every route', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -154,7 +239,12 @@ describe('playersettings endpoints', () => {
|
|||||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect([...documented].sort()).toEqual(['GET /', 'GET /playersettings', 'PUT /playersettings'])
|
expect([...documented].sort()).toEqual([
|
||||||
|
'DELETE /playersettings',
|
||||||
|
'GET /',
|
||||||
|
'GET /playersettings',
|
||||||
|
'PUT /playersettings',
|
||||||
|
])
|
||||||
|
|
||||||
// Every operation carries a summary — a path present but undescribed is not
|
// Every operation carries a summary — a path present but undescribed is not
|
||||||
// documentation.
|
// documentation.
|
||||||
|
|||||||
Reference in New Issue
Block a user