From cf348a397dce47a34d7f0b31fa3e4548d2be48ac Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 4 Jun 2019 14:09:01 -0700 Subject: [PATCH] Feature: List git commits with pagination (#31) --- Dockerfile | 4 + config/tsconfig.build.json | 4 + package-lock.json | 364 +++++++++++------- package.json | 7 +- sdk/test/typescript/test.ts | 3 + .../__snapshots__/api/commit_list_master.json | 17 + .../commits/commits.controller.e2e.ts | 17 + src/controllers/commits/commits.controller.ts | 33 +- src/core/index.ts | 2 + src/core/models/index.ts | 1 + src/core/models/models-decorators.ts | 17 + src/core/pagination/index.ts | 3 + src/core/pagination/paginated-response.ts | 34 ++ src/core/pagination/pagination-decorators.ts | 42 ++ src/core/pagination/pagination.ts | 20 + src/dtos/git-file-diff.ts | 4 +- src/services/commit/commit.service.ts | 88 ++++- swagger-spec.json | 82 +++- test/e2e/custom-matchers.ts | 2 +- tsconfig.json | 2 - 20 files changed, 590 insertions(+), 156 deletions(-) create mode 100644 src/controllers/commits/__snapshots__/api/commit_list_master.json create mode 100644 src/controllers/commits/commits.controller.e2e.ts create mode 100644 src/core/models/index.ts create mode 100644 src/core/models/models-decorators.ts create mode 100644 src/core/pagination/index.ts create mode 100644 src/core/pagination/paginated-response.ts create mode 100644 src/core/pagination/pagination-decorators.ts create mode 100644 src/core/pagination/pagination.ts diff --git a/Dockerfile b/Dockerfile index b49319e..5b8d9b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,10 @@ COPY ./ ./ RUN npm ci && \ rm -f .npmrc + +# Set environment to production +ENV NODE_ENV=production + RUN npm run build CMD npm run start:prod \ No newline at end of file diff --git a/config/tsconfig.build.json b/config/tsconfig.build.json index 11a6a13..1291f0f 100644 --- a/config/tsconfig.build.json +++ b/config/tsconfig.build.json @@ -1,4 +1,8 @@ { + "compilerOptions": { + "rootDir": "../src", + "outDir": "../bin" + }, "extends": "../tsconfig.json", "exclude": ["node_modules", "bin", "../test", "../src/**/*.test.ts", "../src/**/*.e2e.ts"] } diff --git a/package-lock.json b/package-lock.json index 24133d9..9245b8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "git-rest-api", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -384,9 +384,9 @@ } }, "@nestjs/common": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-6.2.0.tgz", - "integrity": "sha512-0svQuCrG6ofMYgHzk/GzJ+vpIkLq4vZmTeww1qLipggS40IjQ0+NzwEBOTr6TeHVDmsM2YDmIU1DJiaGy2o9Hw==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-6.2.4.tgz", + "integrity": "sha512-YZvJ6/S7yVQZK+9rupCzMCg4tpbc9DyVvLoTx0NBDqExTCUNcNEcCtn0AZrO/hLqbeYODnJwGE2NxkH1R/qw+w==", "requires": { "axios": "0.18.0", "cli-color": "1.4.0", @@ -415,6 +415,169 @@ "cors": "2.8.5", "express": "4.16.4", "multer": "1.4.1" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + }, + "express": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz", + "integrity": "sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg==", + "requires": { + "accepts": "~1.3.5", + "array-flatten": "1.1.1", + "body-parser": "1.18.3", + "content-disposition": "0.5.2", + "content-type": "~1.0.4", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.1.1", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.4", + "qs": "6.5.2", + "range-parser": "~1.2.0", + "safe-buffer": "5.1.2", + "send": "0.16.2", + "serve-static": "1.13.2", + "setprototypeof": "1.1.0", + "statuses": "~1.4.0", + "type-is": "~1.6.16", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "body-parser": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.3.tgz", + "integrity": "sha1-WykhmP/dVTs6DyDe0FkrlWlVyLQ=", + "requires": { + "bytes": "3.0.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "~1.6.3", + "iconv-lite": "0.4.23", + "on-finished": "~2.3.0", + "qs": "6.5.2", + "raw-body": "2.3.3", + "type-is": "~1.6.16" + } + } + } + }, + "finalhandler": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.4.0", + "unpipe": "~1.0.0" + } + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "iconv-lite": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", + "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + }, + "raw-body": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.3.tgz", + "integrity": "sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw==", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.3", + "iconv-lite": "0.4.23", + "unpipe": "1.0.0" + } + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + } + }, + "serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.2", + "send": "0.16.2" + } + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + } } }, "@nestjs/swagger": { @@ -2429,9 +2592,12 @@ "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" }, "content-disposition": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", - "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + } }, "content-security-policy-builder": { "version": "2.0.0", @@ -2481,9 +2647,9 @@ } }, "cookie": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", - "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" }, "cookie-signature": { "version": "1.0.6", @@ -3267,103 +3433,46 @@ "integrity": "sha512-6SK3MG/Bbhm8MsgyJAylg+ucIOU71/FzyFalcfu5nY19dH8y/z0tBJU0wrNBXD4B27EoQtqPF/9wqH0iYAd04g==" }, "express": { - "version": "4.16.4", - "resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz", - "integrity": "sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg==", + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", "requires": { - "accepts": "~1.3.5", + "accepts": "~1.3.7", "array-flatten": "1.1.1", - "body-parser": "1.18.3", - "content-disposition": "0.5.2", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", "content-type": "~1.0.4", - "cookie": "0.3.1", + "cookie": "0.4.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "~1.1.2", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.1.1", + "finalhandler": "~1.1.2", "fresh": "0.5.2", "merge-descriptors": "1.0.1", "methods": "~1.1.2", "on-finished": "~2.3.0", - "parseurl": "~1.3.2", + "parseurl": "~1.3.3", "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.4", - "qs": "6.5.2", - "range-parser": "~1.2.0", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", "safe-buffer": "5.1.2", - "send": "0.16.2", - "serve-static": "1.13.2", - "setprototypeof": "1.1.0", - "statuses": "~1.4.0", - "type-is": "~1.6.16", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" }, "dependencies": { - "body-parser": { - "version": "1.18.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.3.tgz", - "integrity": "sha1-WykhmP/dVTs6DyDe0FkrlWlVyLQ=", - "requires": { - "bytes": "3.0.0", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "~1.1.2", - "http-errors": "~1.6.3", - "iconv-lite": "0.4.23", - "on-finished": "~2.3.0", - "qs": "6.5.2", - "raw-body": "2.3.3", - "type-is": "~1.6.16" - } - }, - "bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" - }, - "http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", - "requires": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - } - }, - "iconv-lite": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", - "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "raw-body": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.3.tgz", - "integrity": "sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw==", - "requires": { - "bytes": "3.0.0", - "http-errors": "1.6.3", - "iconv-lite": "0.4.23", - "unpipe": "1.0.0" - } - }, - "setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" - }, - "statuses": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", - "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" } } }, @@ -3541,24 +3650,17 @@ } }, "finalhandler": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", - "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "requires": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", - "parseurl": "~1.3.2", - "statuses": "~1.4.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", "unpipe": "~1.0.0" - }, - "dependencies": { - "statuses": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", - "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" - } } }, "find-up": { @@ -5786,9 +5888,9 @@ } }, "mime": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", - "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" }, "mime-db": { "version": "1.40.0", @@ -7000,9 +7102,9 @@ "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==" }, "send": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", - "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", "requires": { "debug": "2.6.9", "depd": "~1.1.2", @@ -7011,46 +7113,30 @@ "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", - "http-errors": "~1.6.2", - "mime": "1.4.1", - "ms": "2.0.0", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", "on-finished": "~2.3.0", - "range-parser": "~1.2.0", - "statuses": "~1.4.0" + "range-parser": "~1.2.1", + "statuses": "~1.5.0" }, "dependencies": { - "http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", - "requires": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - } - }, - "setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" - }, - "statuses": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", - "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" } } }, "serve-static": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", - "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", "requires": { "encodeurl": "~1.0.2", "escape-html": "~1.0.3", - "parseurl": "~1.3.2", - "send": "0.16.2" + "parseurl": "~1.3.3", + "send": "0.17.1" } }, "set-blocking": { @@ -7831,9 +7917,9 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, "typescript": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.5.tgz", - "integrity": "sha512-YycBxUb49UUhdNMU5aJ7z5Ej2XGmaIBL0x34vZ82fn3hGvD+bgrMrVDpatgz2f7YxUMJxMkbWxJZeAvDxVe7Vw==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.5.1.tgz", + "integrity": "sha512-64HkdiRv1yYZsSe4xC1WVgamNigVYjlssIoaH2HcZF0+ijsk5YK2g0G34w9wJkze8+5ow4STd22AynfO6ZYYLw==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index f128c1f..cca64e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "git-rest-api", - "version": "0.3.1", + "version": "0.3.2", "description": "This project welcomes contributions and suggestions. Most contributions require you to agree to a\r Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us\r the rights to use your contribution. For details, visit https://cla.microsoft.com.", "main": "bin/main.js", "scripts": { @@ -50,10 +50,10 @@ "tslint": "^5.16.0", "tslint-config-prettier": "^1.18.0", "tslint-plugin-prettier": "^2.0.1", - "typescript": "^3.4.5" + "typescript": "^3.5.1" }, "dependencies": { - "@nestjs/common": "^6.2.0", + "@nestjs/common": "^6.2.4", "@nestjs/core": "^6.2.0", "@nestjs/platform-express": "^6.2.0", "@nestjs/swagger": "^3.0.2", @@ -62,6 +62,7 @@ "chalk": "^2.4.2", "class-validator": "^0.9.1", "convict": "^5.0.0", + "express": "^4.17.1", "fast-safe-stringify": "^2.0.6", "helmet": "^3.18.0", "node-fetch": "^2.6.0", diff --git a/sdk/test/typescript/test.ts b/sdk/test/typescript/test.ts index 68c1fb0..df69366 100644 --- a/sdk/test/typescript/test.ts +++ b/sdk/test/typescript/test.ts @@ -10,6 +10,9 @@ async function run() { for (const branch of branches) { console.log(` - ${branch.name} ${branch.commit.sha}`); } + + const response = await sdk.commits.list("github.com/Azure/BatchExplorer"); + console.log("Total commits", response.xTotalCount); } run().catch(e => { diff --git a/src/controllers/commits/__snapshots__/api/commit_list_master.json b/src/controllers/commits/__snapshots__/api/commit_list_master.json new file mode 100644 index 0000000..b6e89a4 --- /dev/null +++ b/src/controllers/commits/__snapshots__/api/commit_list_master.json @@ -0,0 +1,17 @@ +[ + { + "sha": "04ccae1ed572f3cdc277c4df2ac73130efbbba3a", + "message": "Initial commit", + "author": { + "name": "Timothee Guerin", + "email": "timothee.guerin@outlook.com", + "date": "2019-05-23T23:44:59.000Z" + }, + "committer": { + "name": "GitHub", + "email": "noreply@github.com", + "date": "2019-05-23T23:44:59.000Z" + }, + "parents": [] + } +] \ No newline at end of file diff --git a/src/controllers/commits/commits.controller.e2e.ts b/src/controllers/commits/commits.controller.e2e.ts new file mode 100644 index 0000000..27167b8 --- /dev/null +++ b/src/controllers/commits/commits.controller.e2e.ts @@ -0,0 +1,17 @@ +import { TEST_REPO, e2eClient } from "../../../test/e2e"; + +describe("Test commits controller", () => { + it("List commits in the test repo", async () => { + const response = await e2eClient.fetch(`/repos/${TEST_REPO}/commits`); + expect(response.status).toEqual(200); + const content = await response.json(); + expect(content).toMatchPayload("commit_list_master"); + }); + + it("returns empty array if asking for page that doesn't exists", async () => { + const response = await e2eClient.fetch(`/repos/${TEST_REPO}/commits?page=999999`); + expect(response.status).toEqual(200); + const content = await response.json(); + expect(content).toEqual([]); + }); +}); diff --git a/src/controllers/commits/commits.controller.ts b/src/controllers/commits/commits.controller.ts index fb860e5..1eb5572 100644 --- a/src/controllers/commits/commits.controller.ts +++ b/src/controllers/commits/commits.controller.ts @@ -1,7 +1,9 @@ -import { Controller, Get, NotFoundException, Param } from "@nestjs/common"; -import { ApiNotFoundResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { Controller, Get, NotFoundException, Param, Query, Res } from "@nestjs/common"; +import { ApiImplicitQuery, ApiNotFoundResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { Response } from "express"; import { ApiHasPassThruAuth, Auth, RepoAuth } from "../../core"; +import { ApiPaginated, Page, Pagination, applyPaginatedResponse } from "../../core/pagination"; import { GitCommit } from "../../dtos"; import { CommitService } from "../../services"; @@ -9,9 +11,34 @@ import { CommitService } from "../../services"; export class CommitsController { constructor(private commitService: CommitService) {} + @Get() + @ApiHasPassThruAuth() + @ApiNotFoundResponse({}) + @ApiOperation({ title: "List commits", operationId: "commits_list" }) + @ApiImplicitQuery({ + name: "ref", + required: false, + description: "Reference to list the commits from. Can be a branch or a commit. Default to master", + type: String, + }) + @ApiPaginated(GitCommit) + public async list( + @Param("remote") remote: string, + @Query("ref") ref: string | undefined, + @Auth() auth: RepoAuth, + @Page() pagination: Pagination, + @Res() response: Response, + ) { + const commits = await this.commitService.list(remote, { auth, ref, pagination }); + if (commits instanceof NotFoundException) { + throw commits; + } + return applyPaginatedResponse(commits, response); + } + @Get(":commitSha") @ApiHasPassThruAuth() - @ApiOkResponse({ type: GitCommit, isArray: true }) + @ApiOkResponse({ type: GitCommit }) @ApiNotFoundResponse({}) @ApiOperation({ title: "Get a commit", operationId: "commits_get" }) public async get(@Param("remote") remote: string, @Param("commitSha") commitSha: string, @Auth() auth: RepoAuth) { diff --git a/src/core/index.ts b/src/core/index.ts index 1c6e71f..aabb0d1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,2 +1,4 @@ export * from "./repo-auth"; export * from "./logger"; +export * from "./pagination"; +export * from "./models"; diff --git a/src/core/models/index.ts b/src/core/models/index.ts new file mode 100644 index 0000000..174e6d3 --- /dev/null +++ b/src/core/models/index.ts @@ -0,0 +1 @@ +export * from "./models-decorators"; diff --git a/src/core/models/models-decorators.ts b/src/core/models/models-decorators.ts new file mode 100644 index 0000000..60d806a --- /dev/null +++ b/src/core/models/models-decorators.ts @@ -0,0 +1,17 @@ +import { ApiModelProperty } from "@nestjs/swagger"; +import { SwaggerEnumType } from "@nestjs/swagger/dist/types/swagger-enum.type"; + +/** + * Decorator to let swagger know about all the pagination properties available + */ +export function ApiModelEnum(metadata: { [enumName: string]: SwaggerEnumType }): PropertyDecorator { + const enumName = Object.keys(metadata)[0]; + if (!enumName) { + throw new Error("You must provide an enum to ApiModelEnum. `@ApiModelEnum({ Myenum })`"); + } + return ApiModelProperty({ + enum: metadata[enumName], + // Adding custom properties for auto rest + "x-ms-enum": { name: enumName }, + } as any); +} diff --git a/src/core/pagination/index.ts b/src/core/pagination/index.ts new file mode 100644 index 0000000..add39d0 --- /dev/null +++ b/src/core/pagination/index.ts @@ -0,0 +1,3 @@ +export * from "./pagination"; +export * from "./pagination-decorators"; +export * from "./paginated-response"; diff --git a/src/core/pagination/paginated-response.ts b/src/core/pagination/paginated-response.ts new file mode 100644 index 0000000..b2b571a --- /dev/null +++ b/src/core/pagination/paginated-response.ts @@ -0,0 +1,34 @@ +import { Response } from "express"; + +export const TOTAL_COUNT_HEADER = "x-total-count"; + +export interface PaginatedList { + items: T[]; + page: number; + perPage: number; + // Total number of items + total: number; +} + +export function applyPaginatedResponse(attributes: PaginatedList, response: Response) { + if (response.req) { + const originalUrl = `${response.req.protocol}://${response.req.get("host")}${response.req.originalUrl}`; + const nextUrl = getPageUrl(originalUrl, attributes.page + 1); + const lastUrl = getPageUrl(originalUrl, Math.ceil(attributes.total / attributes.perPage)); + const links = [`<${nextUrl}>; rel="next"`, `<${lastUrl}>; rel="last"`]; + + if (attributes.page > 1) { + const prevUrl = getPageUrl(originalUrl, attributes.page - 1); + links.unshift(`<${prevUrl}>; rel="prev"`); + } + response.setHeader("Link", links.join(", ")); + response.setHeader(TOTAL_COUNT_HEADER, attributes.total); + } + response.send(attributes.items); +} + +function getPageUrl(originalUrl: string, page: number) { + const nextUrl = new URL(originalUrl); + nextUrl.searchParams.set("page", page.toString()); + return nextUrl; +} diff --git a/src/core/pagination/pagination-decorators.ts b/src/core/pagination/pagination-decorators.ts new file mode 100644 index 0000000..620faab --- /dev/null +++ b/src/core/pagination/pagination-decorators.ts @@ -0,0 +1,42 @@ +import { createParamDecorator } from "@nestjs/common"; +import { ApiImplicitQuery, ApiResponse } from "@nestjs/swagger"; +import { Request } from "express"; + +import { TOTAL_COUNT_HEADER } from "./paginated-response"; +import { Pagination } from "./pagination"; + +/** + * Decorator to let swagger know about all the pagination properties available + */ +export function ApiPaginated(type: any): MethodDecorator { + const implicitpage = ApiImplicitQuery({ name: "page", required: false, type: String }); + const response = ApiResponse({ status: 200, headers: paginationHeaders, type, isArray: true }); + return (...args) => { + implicitpage(...args); + response(...args); + }; +} + +/** + * Auth param decorator for controller to inject the repo auth object + */ +export const Page = createParamDecorator( + (_, req: Request): Pagination => { + const page = parseInt(req.query.page, 10); + return { + page: isNaN(page) ? undefined : page, + }; + }, +); + +const paginationHeaders = { + Link: { + type: "string", + description: + "Links to navigate pagination in the format defined by [RFC 5988](https://tools.ietf.org/html/rfc5988#section-5). It will include next, last, first and prev links if applicable", + }, + [TOTAL_COUNT_HEADER]: { + type: "integer", + description: "Total count of items that can be retrieved", + }, +}; diff --git a/src/core/pagination/pagination.ts b/src/core/pagination/pagination.ts new file mode 100644 index 0000000..d0640b8 --- /dev/null +++ b/src/core/pagination/pagination.ts @@ -0,0 +1,20 @@ +export interface Pagination { + page?: number; +} + +/** + * Return the given page for the given pagination + * @param pagination + */ +export function getPage(pagination: Pagination | undefined) { + return pagination === undefined || pagination.page === undefined ? 1 : Math.max(pagination.page, 1); +} + +/** + * Return the number of items to skip using the given pagination object + * @param pagination + */ +export function getPaginationSkip(pagination: Pagination | undefined, perPage: number) { + const page = getPage(pagination); + return (page - 1) * perPage; +} diff --git a/src/dtos/git-file-diff.ts b/src/dtos/git-file-diff.ts index 7f7b769..b95310a 100644 --- a/src/dtos/git-file-diff.ts +++ b/src/dtos/git-file-diff.ts @@ -1,5 +1,7 @@ import { ApiModelProperty, ApiModelPropertyOptional } from "@nestjs/swagger"; +import { ApiModelEnum } from "../core"; + export enum PatchStatus { Unmodified = "unmodified", Modified = "modified", @@ -13,7 +15,7 @@ export class GitFileDiff { public filename: string; @ApiModelProperty() public sha: string; - @ApiModelProperty({ enum: PatchStatus }) + @ApiModelEnum({ PatchStatus }) public status: PatchStatus; @ApiModelProperty() public additions: number; diff --git a/src/services/commit/commit.service.ts b/src/services/commit/commit.service.ts index a0b48c1..8d25e7d 100644 --- a/src/services/commit/commit.service.ts +++ b/src/services/commit/commit.service.ts @@ -1,14 +1,39 @@ -import { Injectable } from "@nestjs/common"; -import { Commit, Oid, Repository, Signature, Time } from "nodegit"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Commit, Oid, Repository, Revwalk, Signature, Time } from "nodegit"; +import { PaginatedList, Pagination, getPage, getPaginationSkip } from "../../core"; import { GitCommit, GitCommitRef } from "../../dtos"; import { GitSignature } from "../../dtos/git-signature"; import { GitBaseOptions, RepoService } from "../repo"; +const LIST_COMMIT_PAGE_SIZE = 100; + +export interface ListCommitsOptions { + pagination?: Pagination; + ref?: string; +} + @Injectable() export class CommitService { constructor(private repoService: RepoService) {} + public async list( + remote: string, + options: ListCommitsOptions & GitBaseOptions = {}, + ): Promise | NotFoundException> { + const repo = await this.repoService.get(remote, options); + const commits = await this.listCommits(repo, options); + if (commits instanceof NotFoundException) { + return commits; + } + + const items = await Promise.all(commits.items.map(async x => toGitCommit(x))); + return { + ...commits, + items, + }; + } + public async get(remote: string, commitSha: string, options: GitBaseOptions = {}): Promise { const repo = await this.repoService.get(remote, options); const commit = await this.getCommit(repo, commitSha); @@ -40,6 +65,51 @@ export class CommitService { } } } + + public async getCommitOrDefault(repo: Repository, ref: string | undefined) { + if (ref) { + return this.getCommit(repo, ref); + } else { + const branch = await repo.getCurrentBranch(); + const name = branch.shorthand(); + return repo.getReferenceCommit(`origin/${name}`); + } + } + + public async listCommits( + repo: Repository, + options: ListCommitsOptions, + ): Promise | NotFoundException> { + const walk = repo.createRevWalk(); + + const page = getPage(options.pagination); + const commit = await this.getCommitOrDefault(repo, options.ref); + if (!commit) { + return new NotFoundException(`Couldn't find reference with name ${options.ref}`); + } + walk.push(commit.id()); + + const skip = getPaginationSkip(options.pagination, LIST_COMMIT_PAGE_SIZE); + await walkSkip(walk, skip); + const commits = await walk.getCommits(LIST_COMMIT_PAGE_SIZE); + + let total = skip + LIST_COMMIT_PAGE_SIZE; + + while (true) { + try { + await walk.next(); + total++; + } catch (e) { + break; + } + } + return { + items: commits, + page, + total, + perPage: LIST_COMMIT_PAGE_SIZE, + }; + } } export async function toGitCommit(commit: Commit): Promise { @@ -80,3 +150,17 @@ export function getSignature(sig: Signature): GitSignature { export function getDateFromTime(time: Time): Date { return new Date(time.time() * 1000); } + +/** + * Try to skip the given number of item in the walk. + * If there is less than ask remaining it will just stop gracfully + */ +async function walkSkip(revwalk: Revwalk, skip: number) { + for (let i = 0; i < skip; i++) { + try { + await revwalk.next(); + } catch { + return; + } + } +} diff --git a/swagger-spec.json b/swagger-spec.json index e5cc46c..3c43b0b 100644 --- a/swagger-spec.json +++ b/swagger-spec.json @@ -78,6 +78,78 @@ ] } }, + "/repos/{remote}/commits": { + "get": { + "summary": "List commits", + "operationId": "commits_list", + "parameters": [ + { + "type": "string", + "name": "remote", + "required": true, + "in": "path" + }, + { + "name": "page", + "required": false, + "in": "query", + "type": "string" + }, + { + "name": "ref", + "required": false, + "in": "query", + "description": "Reference to list the commits from. Can be a branch or a commit. Default to master", + "type": "string" + }, + { + "name": "x-authorization", + "required": false, + "in": "header", + "type": "string" + }, + { + "name": "x-github-token", + "required": false, + "in": "header", + "type": "string" + } + ], + "responses": { + "200": { + "headers": { + "Link": { + "type": "string", + "description": "Links to navigate pagination in the format defined by [RFC 5988](https://tools.ietf.org/html/rfc5988#section-5). It will include next, last, first and prev links if applicable" + }, + "x-total-count": { + "type": "integer", + "description": "Total count of items that can be retrieved" + } + }, + "description": "", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/GitCommit" + } + } + }, + "400": { + "description": "When the x-authorization header is malformed" + }, + "404": { + "description": "" + } + }, + "produces": [ + "application/json" + ], + "consumes": [ + "application/json" + ] + } + }, "/repos/{remote}/commits/{commitSha}": { "get": { "summary": "Get a commit", @@ -112,10 +184,7 @@ "200": { "description": "", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/GitCommit" - } + "$ref": "#/definitions/GitCommit" } }, "400": { @@ -338,7 +407,10 @@ "added", "deleted", "renamed" - ] + ], + "x-ms-enum": { + "name": "PatchStatus" + } }, "additions": { "type": "number" diff --git a/test/e2e/custom-matchers.ts b/test/e2e/custom-matchers.ts index a98cec7..e949efb 100644 --- a/test/e2e/custom-matchers.ts +++ b/test/e2e/custom-matchers.ts @@ -66,7 +66,7 @@ function toMatchSpecificSnapshot( } if (fs.existsSync(filepath)) { - const output = fs.readFileSync(filepath, "utf8"); + const output = fs.readFileSync(filepath, "utf8").replace(/\r\n/g, "\n"); // The matcher is being used with `.not` if (output === content) { this.snapshotState.matched++; diff --git a/tsconfig.json b/tsconfig.json index 37d1177..93186dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,6 @@ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ // "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ - "rootDir": "src", - "outDir": "bin", // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ // "composite": true, /* Enable project compilation */ // "incremental": true, /* Enable incremental compilation */