diff --git a/.npmignore b/.npmignore deleted file mode 100644 index a7ac4f6..0000000 --- a/.npmignore +++ /dev/null @@ -1,16 +0,0 @@ -.github -.DS_Store -.editorconfig -.gitignore -.gitmodules -.lgtm.yml -appveyor.yml -codecov.yml -.release -.travis.yml -.eslintrc.yaml -.eslintrc.json -.codeclimate.yml -test/ -DEVELOP.md -.prettierrc.yml \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c1f65fa..dea1ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +### [3.0.0-alpha.8.1] - 2026-03-15 + +- feat(zone): use DataTable for list, added search/limit options +- routes/zr: add extra data about ZR parse failures + ### [3.0.0-alpha.8] - 2026-03-14 - lib/zone: add limit option @@ -57,8 +62,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). [3.0.0-alpha.1]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.1 [3.0.0-alpha.2]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.2 [3.0.0-alpha.3]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.3 -[3.0.0-alpha.4]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.4 -[3.0.0-alpha.5]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.5 -[3.0.0-alpha.6]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.6 +[3.0.0-alpha.4]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.4 +[3.0.0-alpha.5]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.5 +[3.0.0-alpha.6]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.6 [3.0.0-alpha.7]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.7 [3.0.0-alpha.8]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.8 +[3.0.0-alpha.8.1]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.8.1 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 3547fbb..b321d59 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -2,7 +2,7 @@ This handcrafted artisanal software is brought to you by: -|
msimerson (15)| +|
msimerson (16)| | :---: | this file is generated by [.release](https://github.com/msimerson/.release). diff --git a/lib/zone.js b/lib/zone.js index e3250b0..6f2f356 100644 --- a/lib/zone.js +++ b/lib/zone.js @@ -4,6 +4,36 @@ import { mapToDbColumn } from './util.js' const zoneDbMap = { id: 'nt_zone_id', gid: 'nt_group_id' } const boolFields = ['deleted'] +function applyZoneFilters(query, params, filters = {}) { + let nextQuery = query + const nextParams = [...params] + + const append = (sql) => { + nextQuery += `${/\bWHERE\b/.test(nextQuery) ? ' AND' : ' WHERE'} ${sql}` + } + + const search = typeof filters.search === 'string' ? filters.search.trim() : '' + if (search) { + append('(zone LIKE ? OR description LIKE ?)') + const wildcard = `%${search}%` + nextParams.push(wildcard, wildcard) + } + + const zoneLike = typeof filters.zone_like === 'string' ? filters.zone_like.trim() : '' + if (zoneLike) { + append('zone LIKE ?') + nextParams.push(`%${zoneLike}%`) + } + + const descriptionLike = typeof filters.description_like === 'string' ? filters.description_like.trim() : '' + if (descriptionLike) { + append('description LIKE ?') + nextParams.push(`%${descriptionLike}%`) + } + + return [nextQuery, nextParams] +} + class Zone { constructor() { this.mysql = Mysql @@ -20,12 +50,34 @@ class Zone { async get(args) { args = JSON.parse(JSON.stringify(args)) - if (args.deleted === undefined) args.deleted = false + args.deleted = args.deleted ?? false + + const filters = { + search: args.search, + zone_like: args.zone_like, + description_like: args.description_like, + } + delete args.search + delete args.zone_like + delete args.description_like + + const sortByMap = { + id: 'nt_zone_id', + zone: 'zone', + description: 'description', + last_modified: 'last_modified', + } + const sortBy = sortByMap[args.sort_by] ?? 'zone' + const sortDir = args.sort_dir === 'desc' ? 'DESC' : 'ASC' + delete args.sort_by + delete args.sort_dir const limit = Number.isInteger(args.limit) ? args.limit : undefined delete args.limit + const offset = Number.isInteger(args.offset) ? Math.max(0, args.offset) : 0 + delete args.offset - const sqlLimit = limit === undefined ? '' : ` LIMIT ${Math.max(1, limit)}` + const sqlLimit = limit === undefined ? '' : ` LIMIT ${Math.max(1, limit)} OFFSET ${offset}` const [query, params] = Mysql.select( `SELECT nt_zone_id AS id @@ -46,7 +98,10 @@ class Zone { mapToDbColumn(args, zoneDbMap), ) - const rows = await Mysql.execute(`${query}${sqlLimit}`, params) + let [finalQuery, finalParams] = applyZoneFilters(query, params, filters) + finalQuery += ` ORDER BY ${sortBy} ${sortDir}` + + const rows = await Mysql.execute(`${finalQuery}${sqlLimit}`, finalParams) for (const row of rows) { for (const b of boolFields) { row[b] = row[b] === 1 @@ -77,6 +132,30 @@ class Zone { return rows } + async count(args = {}) { + args = JSON.parse(JSON.stringify(args)) + args.deleted = args.deleted ?? false + + const filters = { + search: args.search, + zone_like: args.zone_like, + description_like: args.description_like, + } + delete args.search + delete args.zone_like + delete args.description_like + + const [query, params] = Mysql.select( + `SELECT COUNT(*) AS total + FROM nt_zone`, + mapToDbColumn(args, zoneDbMap), + ) + + const [finalQuery, finalParams] = applyZoneFilters(query, params, filters) + const rows = await Mysql.execute(finalQuery, finalParams) + return rows?.[0]?.total ?? 0 + } + async put(args) { if (!args.id) return false const id = args.id diff --git a/lib/zone_record.js b/lib/zone_record.js index 25ae4e1..e6b391e 100644 --- a/lib/zone_record.js +++ b/lib/zone_record.js @@ -58,7 +58,6 @@ class ZoneRecord { for (const b of boolFields) { row[b] = row[b] === 1 } - if (args.deleted === false) delete row.deleted } diff --git a/package.json b/package.json index c9ef7ba..bea6a7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nictool/api", - "version": "3.0.0-alpha.8", + "version": "3.0.0-alpha.8.1", "description": "NicTool API", "main": "index.js", "type": "module", diff --git a/routes/zone.js b/routes/zone.js index ea233dc..dd4d714 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -38,13 +38,32 @@ function ZoneRoutes(server) { tags: ['api'], }, handler: async (request, h) => { + const deleted = request.query.deleted === true const getArgs = { - deleted: request.query.deleted === true ? 1 : 0, - limit: 1000, + deleted, + limit: Number.isInteger(request.query.limit) ? request.query.limit : 1000, } if (request.params.id) getArgs.id = parseInt(request.params.id, 10) + if (request.query.search) getArgs.search = request.query.search + if (Number.isInteger(request.query.offset)) getArgs.offset = request.query.offset + if (request.query.zone_like) getArgs.zone_like = request.query.zone_like + if (request.query.description_like) getArgs.description_like = request.query.description_like + if (request.query.sort_by) getArgs.sort_by = request.query.sort_by + if (request.query.sort_dir) getArgs.sort_dir = request.query.sort_dir - const zones = await Zone.get(getArgs) + const countArgs = { + deleted, + ...(getArgs.id ? { id: getArgs.id } : {}), + ...(getArgs.search ? { search: getArgs.search } : {}), + ...(getArgs.zone_like ? { zone_like: getArgs.zone_like } : {}), + ...(getArgs.description_like ? { description_like: getArgs.description_like } : {}), + } + + const [zones, filtered, total] = await Promise.all([ + Zone.get(getArgs), + Zone.count(countArgs), + Zone.count(getArgs.id ? { deleted, id: getArgs.id } : { deleted }), + ]) return h .response({ @@ -52,6 +71,12 @@ function ZoneRoutes(server) { meta: { api: meta.api, msg: `here's your zone(s)`, + pagination: { + total, + filtered, + limit: getArgs.limit, + offset: getArgs.offset ?? 0, + }, }, }) .code(200) @@ -103,7 +128,7 @@ function ZoneRoutes(server) { }, handler: async (request, h) => { const zones = await Zone.get({ - deleted: request.query.deleted === true ? 1 : 0, + deleted: request.query.deleted === true, id: parseInt(request.params.id, 10), }) diff --git a/routes/zone.test.js b/routes/zone.test.js index 821548e..8ebe852 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -54,6 +54,16 @@ describe('zone routes', () => { assert.equal(res.result.zone[0].zone, nsCase.zone) }) + it('GET /zone?search=... returns DB matches', async () => { + const res = await server.inject({ + method: 'GET', + url: '/zone?search=route.example', + headers: auth.headers, + }) + assert.equal(res.statusCode, 200) + assert.ok(res.result.zone.some((z) => z.zone === nsCase.zone)) + }) + it(`POST /zone (${case2Id})`, async () => { const testCase = JSON.parse(JSON.stringify(nsCase)) testCase.id = case2Id // make it unique diff --git a/routes/zone_record.js b/routes/zone_record.js index aafd664..137dbb8 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -1,8 +1,32 @@ import validate from '@nictool/validate' import ZoneRecord from '../lib/zone_record.js' +import Zone from '../lib/zone.js' import { meta } from '../lib/util.js' +async function zoneRecordResponseFailAction(request, h, err) { + const detail = err?.details?.find( + (d) => Array.isArray(d.path) && d.path[0] === 'zone_record' && d.path[2] === 'owner', + ) + + if (detail) { + const index = detail.path[1] + const badRecord = request.response?.source?.zone_record?.[index] + + if (badRecord) { + let zoneName = 'unknown' + if (Number.isInteger(badRecord.zid)) { + const zones = await Zone.get({ id: badRecord.zid, deleted: 0 }) + if (zones.length > 0) zoneName = zones[0].zone + } + + err.message = `${err.message}. Invalid zone record owner for zone "${zoneName}" (zone id: ${badRecord.zid ?? 'unknown'}, record id: ${badRecord.id ?? 'unknown'}, owner: "${badRecord.owner ?? 'unknown'}")` + } + } + + throw err +} + function ZoneRecordRoutes(server) { server.route([ { @@ -15,7 +39,7 @@ function ZoneRecordRoutes(server) { }, response: { schema: validate.zone_record.GET_res, - failAction: 'log', + failAction: zoneRecordResponseFailAction, }, tags: ['api'], },