diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9ca066c..a0c075c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,3 +32,6 @@ jobs: - name: Run Formatting run: npm run format:check + + - name: Run property tests + run: npm run test:property diff --git a/package-lock.json b/package-lock.json index fb3576b..b40f1dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "commander": "^14.0.0", "esbuild": "^0.25.5", "eslint": "^9.29.0", + "fast-check": "^4.7.0", "glob": "11.1.0", "i": "^0.3.7", "lodash": "^4.17.21", @@ -102,6 +103,74 @@ "kuler": "^2.0.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.5", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", @@ -118,6 +187,346 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -2061,6 +2470,29 @@ "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5482,6 +5914,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", diff --git a/package.json b/package.json index 9a03984..753b951 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "node dist/index.js", "clean": "rm -rf dist", "lint": "eslint --ext .ts .", + "test:property": "node --import tsx scripts/check-property-tests.ts", "inspect": "tsc -w & nodemon --watch dist --exec 'npx @modelcontextprotocol/inspector node dist/index.js'", "genql.scout": "genql --endpoint https://api.scout.docker.com/v1/graphql --output ./src/scout/genql", "format:fix": "prettier --write \"**/*.+(ts|json)\"", @@ -41,6 +42,7 @@ "commander": "^14.0.0", "esbuild": "^0.25.5", "eslint": "^9.29.0", + "fast-check": "^4.7.0", "glob": "11.1.0", "i": "^0.3.7", "lodash": "^4.17.21", diff --git a/scripts/check-property-tests.ts b/scripts/check-property-tests.ts new file mode 100644 index 0000000..0b34d57 --- /dev/null +++ b/scripts/check-property-tests.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import fc from 'fast-check'; +import { + buildNamespaceRepositoriesUrl, + buildRepositoryTagsUrl, + buildSearchQueryParams, + buildSearchUrl, +} from '../src/query-builders'; + +const arrayValueArb = fc.string({ minLength: 1, maxLength: 16 }).filter((value) => { + return !value.includes(','); +}); +const optionalStringArb = fc.option(fc.string({ minLength: 1, maxLength: 24 }), { + nil: undefined, +}); +const optionalArrayArb = fc.option(fc.array(arrayValueArb, { maxLength: 4 }), { + nil: undefined, +}); + +fc.assert( + fc.property( + fc.record({ + query: fc.string({ minLength: 1, maxLength: 24 }), + badges: optionalArrayArb, + type: optionalStringArb, + categories: optionalArrayArb, + architectures: optionalArrayArb, + operating_systems: optionalArrayArb, + extension_reviewed: fc.option(fc.boolean(), { nil: undefined }), + from: fc.option(fc.integer({ min: 0, max: 500 }), { nil: undefined }), + size: fc.option(fc.integer({ min: 0, max: 500 }), { nil: undefined }), + sort: fc.option(fc.constantFrom('pull_count', 'updated_at'), { nil: null }), + order: fc.option(fc.constantFrom('asc', 'desc'), { nil: null }), + images: optionalArrayArb, + }), + (request) => { + const params = buildSearchQueryParams(request); + + assert.equal(params.get('query'), request.query); + assert.equal(params.get('type'), request.type ?? null); + assert.equal(params.get('sort'), request.sort ?? null); + assert.equal(params.get('order'), request.order ?? null); + assert.equal( + params.get('from'), + request.from === undefined ? null : request.from.toString() + ); + assert.equal( + params.get('size'), + request.size === undefined ? null : request.size.toString() + ); + assert.equal( + params.get('extension_reviewed'), + request.extension_reviewed ? 'true' : null + ); + + for (const key of [ + 'badges', + 'categories', + 'architectures', + 'operating_systems', + 'images', + ] as const) { + const expected = request[key]?.length ? request[key]!.join(',') : null; + assert.equal(params.get(key), expected); + } + + const builtUrl = new URL(buildSearchUrl('https://hub.docker.com/api/search', request)); + assert.equal(builtUrl.searchParams.get('custom_boosted_results'), 'true'); + assert.equal(builtUrl.searchParams.get('query'), request.query); + } + ) +); + +fc.assert( + fc.property( + fc.record({ + namespace: fc.string({ minLength: 1, maxLength: 20 }), + page: fc.option(fc.integer({ min: 1, max: 50 }), { nil: undefined }), + page_size: fc.option(fc.integer({ min: 1, max: 100 }), { nil: undefined }), + ordering: optionalStringArb, + media_types: optionalStringArb, + content_types: optionalStringArb, + }), + (request) => { + const url = new URL( + buildNamespaceRepositoriesUrl('https://hub.docker.com/v2', request) + ); + + assert.equal(url.searchParams.get('page'), (request.page ?? 1).toString()); + assert.equal(url.searchParams.get('page_size'), (request.page_size ?? 10).toString()); + assert.equal(url.searchParams.get('ordering'), request.ordering ?? null); + assert.equal(url.searchParams.get('media_types'), request.media_types ?? null); + assert.equal(url.searchParams.get('content_types'), request.content_types ?? null); + } + ) +); + +fc.assert( + fc.property( + fc.record({ + namespace: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { + nil: undefined, + }), + repository: fc.string({ minLength: 1, maxLength: 20 }), + page: fc.option(fc.integer({ min: 1, max: 50 }), { nil: undefined }), + page_size: fc.option(fc.integer({ min: 1, max: 100 }), { nil: undefined }), + architecture: optionalStringArb, + os: optionalStringArb, + }), + (request) => { + const url = new URL(buildRepositoryTagsUrl('https://hub.docker.com/v2', request)); + + assert.equal(url.searchParams.get('page'), (request.page ?? 1).toString()); + assert.equal(url.searchParams.get('page_size'), (request.page_size ?? 10).toString()); + assert.equal(url.searchParams.get('architecture'), request.architecture ?? null); + assert.equal(url.searchParams.get('os'), request.os ?? null); + assert.ok(url.pathname.includes(`/${encodeURIComponent(request.repository)}/tags`)); + } + ) +); + +console.log('Property-based checks passed.'); diff --git a/src/query-builders.ts b/src/query-builders.ts new file mode 100644 index 0000000..ad6de54 --- /dev/null +++ b/src/query-builders.ts @@ -0,0 +1,139 @@ +export type SearchRequest = { + query: string; + badges?: string[]; + type?: string; + categories?: string[]; + architectures?: string[]; + operating_systems?: string[]; + extension_reviewed?: boolean; + from?: number; + size?: number; + sort?: 'pull_count' | 'updated_at' | null; + order?: 'asc' | 'desc' | null; + images?: string[]; +}; + +export function buildSearchQueryParams(request: SearchRequest): URLSearchParams { + const params = new URLSearchParams(); + + params.set('query', request.query); + + if (request.badges?.length) { + params.set('badges', request.badges.join(',')); + } + if (request.type) { + params.set('type', request.type); + } + if (request.categories?.length) { + params.set('categories', request.categories.join(',')); + } + if (request.architectures?.length) { + params.set('architectures', request.architectures.join(',')); + } + if (request.operating_systems?.length) { + params.set('operating_systems', request.operating_systems.join(',')); + } + if (request.extension_reviewed) { + params.set('extension_reviewed', 'true'); + } + if (request.from !== undefined && request.from !== null) { + params.set('from', request.from.toString()); + } + if (request.size !== undefined && request.size !== null) { + params.set('size', request.size.toString()); + } + if (request.sort) { + params.set('sort', request.sort); + } + if (request.order) { + params.set('order', request.order); + } + if (request.images?.length) { + params.set('images', request.images.join(',')); + } + + return params; +} + +export function buildSearchUrl(host: string, request: SearchRequest): string { + const url = new URL(`${host}/v4?custom_boosted_results=true`); + const params = buildSearchQueryParams(request); + + for (const [key, value] of params.entries()) { + url.searchParams.set(key, value); + } + + return url.toString(); +} + +export function buildNamespaceRepositoriesUrl( + host: string, + { + namespace, + page, + page_size, + ordering, + media_types, + content_types, + }: { + namespace: string; + page?: number; + page_size?: number; + ordering?: string; + media_types?: string; + content_types?: string; + } +): string { + const url = new URL(`${host}/namespaces/${encodeURIComponent(namespace)}/repositories`); + + url.searchParams.set('page', (page ?? 1).toString()); + url.searchParams.set('page_size', (page_size ?? 10).toString()); + + if (ordering) { + url.searchParams.set('ordering', ordering); + } + if (media_types) { + url.searchParams.set('media_types', media_types); + } + if (content_types) { + url.searchParams.set('content_types', content_types); + } + + return url.toString(); +} + +export function buildRepositoryTagsUrl( + host: string, + { + namespace, + repository, + page, + page_size, + architecture, + os, + }: { + namespace?: string; + repository: string; + page?: number; + page_size?: number; + architecture?: string; + os?: string; + } +): string { + const resolvedNamespace = namespace || 'library'; + const url = new URL( + `${host}/namespaces/${encodeURIComponent(resolvedNamespace)}/repositories/${encodeURIComponent(repository)}/tags` + ); + + url.searchParams.set('page', (page ?? 1).toString()); + url.searchParams.set('page_size', (page_size ?? 10).toString()); + + if (architecture) { + url.searchParams.set('architecture', architecture); + } + if (os) { + url.searchParams.set('os', os); + } + + return url.toString(); +} diff --git a/src/repos.ts b/src/repos.ts index c9ca187..6eba543 100644 --- a/src/repos.ts +++ b/src/repos.ts @@ -20,6 +20,7 @@ import { z } from 'zod'; import { createPaginatedResponseSchema } from './types'; import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { logger } from './logger'; +import { buildNamespaceRepositoriesUrl, buildRepositoryTagsUrl } from './query-builders'; //#region Types // all items in the types are optional and nullable because structured content is always evaluated even when an error occurs. @@ -494,22 +495,14 @@ export class Repos extends Asset { if (!namespace) { throw new Error('Namespace is required'); } - if (!page) { - page = 1; - } - if (!page_size) { - page_size = 10; - } - let url = `${this.config.host}/namespaces/${namespace}/repositories?page=${page}&page_size=${page_size}`; - if (ordering) { - url += `&ordering=${ordering}`; - } - if (media_types) { - url += `&media_types=${media_types}`; - } - if (content_types) { - url += `&content_types=${content_types}`; - } + const url = buildNamespaceRepositoriesUrl(this.config.host, { + namespace, + page, + page_size, + ordering, + media_types, + content_types, + }); return this.callAPI( url, @@ -534,29 +527,14 @@ export class Repos extends Asset { architecture?: string; os?: string; }): Promise { - if (!namespace) { - namespace = 'library'; - } - if (!page) { - page = 1; - } - if (!page_size) { - page_size = 10; - } - let url = `${this.config.host}/namespaces/${namespace}/repositories/${repository}/tags`; - const params: Record = { - page: page.toString(), - page_size: page_size.toString(), - }; - if (architecture) { - params.architecture = architecture; - } - if (os) { - params.os = os; - } - if (Object.keys(params).length > 0) { - url += `?${new URLSearchParams(params).toString()}`; - } + const url = buildRepositoryTagsUrl(this.config.host, { + namespace, + repository, + page, + page_size, + architecture, + os, + }); return this.callAPI( url, diff --git a/src/search.ts b/src/search.ts index be3a845..6104b54 100644 --- a/src/search.ts +++ b/src/search.ts @@ -19,6 +19,7 @@ import { Asset, AssetConfig } from './asset'; import { z } from 'zod'; import { CallToolResult } from '@modelcontextprotocol/sdk/types'; import { logger } from './logger'; +import { buildSearchUrl, SearchRequest } from './query-builders'; //#region Types const searchResult = z.object({ @@ -157,22 +158,8 @@ export class Search extends Asset { ); } - private async search(request: { - query: string; - badges?: string[]; - type?: string; - categories?: string[]; - architectures?: string[]; - operating_systems?: string[]; - extension_reviewed?: boolean; - from?: number; - size?: number; - sort?: 'pull_count' | 'updated_at' | null; - order?: 'asc' | 'desc' | null; - images?: string[]; - }): Promise { + private async search(request: SearchRequest): Promise { logger.info(`Searching for repositories with request: ${JSON.stringify(request)}`); - let url = `${this.config.host}/v4?custom_boosted_results=true`; if (!request.query) { return { content: [{ type: 'text', text: 'Please provide a query to search for' }], @@ -180,51 +167,7 @@ export class Search extends Asset { isError: true, }; } - const queryParams = new URLSearchParams(); - for (const key in request) { - const param = key as keyof typeof request; - switch (param) { - case 'badges': - case 'categories': - case 'architectures': - case 'operating_systems': - case 'images': { - if (request[param] && request[param].length > 0) { - queryParams.set(param, request[param].join(',')); - } - break; - } - case 'query': - case 'type': - case 'order': - case 'sort': - case 'from': - case 'size': { - if ( - request[param] !== undefined && - request[param] !== null && - request[param] !== '' - ) { - queryParams.set(param, request[param].toString()); - logger.info(`Setting parameter: ${param} to ${request[param]}`); - } - break; - } - case 'extension_reviewed': { - if (request[param]) { - queryParams.set(param, 'true'); - } - break; - } - default: { - logger.warn(`Unknown parameter: ${param}`); - break; - } - } - } - if (queryParams.size > 0) { - url += `&${queryParams.toString()}`; - } + const url = buildSearchUrl(this.config.host, request); const response = await this.callAPI( url, { method: 'GET' },