diff --git a/CHANGELOG.md b/CHANGELOG.md index f354553..9e413fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog + ## 0.4.0 +- **[BREAKING]** Include line breaks every 60 characters in base64 encoded strings to mirror what the actual GitHub API does +- Add support for Git blobs endpoints (https://docs.github.com/en/rest/git/blobs?apiVersion=2022-11-28#get-a-blob) + ## 0.3.0 - Correctly handle binary files in create-blob! and get-blob operations - Fix reflective accesses in clj-github-mock.impl.jgit diff --git a/project.clj b/project.clj index 781f17f..6006b6e 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject dev.nubank/clj-github-mock "0.3.0" +(defproject dev.nubank/clj-github-mock "0.4.0" :description "An emulator of the github api" :url "https://github.com/nubank/clj-github-mock" :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" diff --git a/src/clj_github_mock/handlers/repos.clj b/src/clj_github_mock/handlers/repos.clj index c77cc41..f9e1ed4 100644 --- a/src/clj_github_mock/handlers/repos.clj +++ b/src/clj_github_mock/handlers/repos.clj @@ -49,6 +49,16 @@ :body body} {:status 404})) +(defn post-blob-handler [{{git-repo :repo/jgit} :repo + body :body}] + {:status 201 + :body (jgit/create-blob! git-repo body)}) + +(defn get-blob-handler [{{git-repo :repo/jgit} :repo + {:keys [sha]} :path-params}] + {:status 200 + :body (jgit/get-blob git-repo sha)}) + (defn post-commit-handler [{{git-repo :repo/jgit} :repo body :body}] {:status 201 @@ -126,6 +136,8 @@ :patch patch-repo-handler}] ["/git/trees" {:post post-tree-handler}] ["/git/trees/:sha" {:get get-tree-handler}] + ["/git/blobs" {:post post-blob-handler}] + ["/git/blobs/:sha" {:get get-blob-handler}] ["/git/commits" {:post post-commit-handler}] ["/git/commits/:sha" {:get get-commit-handler}] ["/git/refs" {:post post-ref-handler}] diff --git a/src/clj_github_mock/impl/base64.clj b/src/clj_github_mock/impl/base64.clj index 22dc2c1..8d3e682 100644 --- a/src/clj_github_mock/impl/base64.clj +++ b/src/clj_github_mock/impl/base64.clj @@ -1,4 +1,5 @@ (ns clj-github-mock.impl.base64 + (:require [clojure.string :as str]) (:import (java.nio.charset StandardCharsets) (java.util Base64 Base64$Decoder Base64$Encoder))) @@ -7,11 +8,27 @@ (def ^:private ^Base64$Encoder base64-encoder (Base64/getEncoder)) (def ^:private ^Base64$Decoder base64-decoder (Base64/getDecoder)) +(defn- line-wrap + "Includes line breaks in the provided string `s` every `limit` characters. + + Used to mirror GitHub API's behavior that includes breaks in some + base64-encoded strings." + ^String [s limit] + (->> s + (partition-all limit) + (map str/join) + (str/join "\n"))) + +(defn- unwrap-lines + "Strips line breaks from a base64-encoded string." + ^String [s] + (str/replace s "\n" "")) + (defn encode-bytes->str "Encodes the given byte array to its Base64 representation." ^String [^bytes bs] (let [data (.encode base64-encoder bs)] - (String. data StandardCharsets/UTF_8))) + (line-wrap (String. data StandardCharsets/UTF_8) 60))) (defn encode-str->str "Encodes the given String to its Base64 representation using UTF-8." @@ -21,7 +38,7 @@ (defn decode-str->bytes "Decodes the given Base64 String to a byte array." ^bytes [^String s] - (let [bs (.getBytes s StandardCharsets/UTF_8)] + (let [bs (.getBytes (unwrap-lines s) StandardCharsets/UTF_8)] (.decode base64-decoder bs))) (defn decode-str->str diff --git a/src/clj_github_mock/impl/jgit.clj b/src/clj_github_mock/impl/jgit.clj index df8618f..b38cc0d 100644 --- a/src/clj_github_mock/impl/jgit.clj +++ b/src/clj_github_mock/impl/jgit.clj @@ -29,8 +29,11 @@ (let [object-loader (.open reader object-id)] (.getBytes object-loader))) -(defn- insert-blob [^ObjectInserter inserter {:keys [content]}] - (let [^bytes bs (if (bytes? content) content (.getBytes ^String content "UTF-8"))] +(defn- insert-blob [^ObjectInserter inserter {:keys [content encoding]}] + ; https://docs.github.com/en/rest/git/blobs?apiVersion=2022-11-28#create-a-blob + (let [^bytes bs (if (= encoding "base64") + (base64/decode-str->bytes content) + (.getBytes ^String content "UTF-8"))] (.insert inserter Constants/OBJ_BLOB bs))) (defn create-blob! [repo blob] @@ -39,8 +42,10 @@ {:sha (ObjectId/toString object-id)}))) (defn get-blob [repo sha] + ; https://docs.github.com/en/rest/git/blobs?apiVersion=2022-11-28#get-a-blob (let [content (load-object (new-reader repo) (ObjectId/fromString sha))] - {:content (base64/encode-bytes->str content)})) + {:content (base64/encode-bytes->str content) + :encoding "base64"})) (def ^:private github-mode->file-mode {"100644" FileMode/REGULAR_FILE "100755" FileMode/EXECUTABLE_FILE @@ -150,6 +155,7 @@ ; NOTE: when reading the flattened tree, contents are always assumed to be a String ; (needed for backwards compatibility) (update :content #(if (string/blank? %) % (base64/decode-str->str %))) + (assoc :encoding "utf-8") (update :path (partial concat-path base-path)) (dissoc :sha))])) tree))) @@ -225,6 +231,7 @@ :commit (dissoc commit :sha)}}))) (defn get-content [repo sha path] + ; https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-repository-content (let [reader (new-reader repo) commit (RevCommit/parse (load-object reader (ObjectId/fromString sha))) tree-id (-> commit (.getTree) (.getId)) @@ -234,4 +241,5 @@ (let [content (load-object reader object-id)] {:type "file" :path path + :encoding "base64" :content (base64/encode-bytes->str content)})))) diff --git a/test/clj_github_mock/handlers/repos_test.clj b/test/clj_github_mock/handlers/repos_test.clj index 4127417..5c8b582 100644 --- a/test/clj_github_mock/handlers/repos_test.clj +++ b/test/clj_github_mock/handlers/repos_test.clj @@ -11,7 +11,8 @@ [malli.core :as m] [matcher-combinators.standalone :refer [match?]] [matcher-combinators.test] - [ring.mock.request :as mock])) + [ring.mock.request :as mock]) + (:import (java.util Arrays))) (defn org-repos-path [org-name] (str "/orgs/" org-name "/repos")) @@ -282,9 +283,54 @@ (= {:status 200 :body {:type "file" :path (:path file) + :encoding "base64" :content (base64/encode-str->str (:content file))}} (handler (get-content-request (:org/name org0) (:repo/name repo0) (:path file) (-> branch :commit :sha)))))) +(defn create-binary-blob-request [org repo contents] + (let [path (str "/repos/" org "/" repo "/git/blobs") + req (mock/request :post path) + body {:content (base64/encode-bytes->str contents) + :encoding "base64"}] + (assoc req :body body))) + +(defn create-string-blob-request [org repo contents] + (let [path (str "/repos/" org "/" repo "/git/blobs") + req (mock/request :post path) + body {:content contents}] + (assoc req :body body))) + +(defn get-blob-request [org repo sha] + (let [path (str "/repos/" org "/" repo "/git/blobs/" sha) + req (mock/request :get path)] + req)) + +(defspec create-and-get-binary-blob + (prop/for-all + [{:keys [handler org0 repo0]} (mock-gen/database {:repo [[1]]}) + ^bytes contents gen/bytes] + (let [{create-blob-status :status + {blob-sha :sha} :body} (handler (create-binary-blob-request (:org/name org0) (:repo/name repo0) contents)) + {get-blob-status :status + get-blob-body :body} (handler (get-blob-request (:org/name org0) (:repo/name repo0) blob-sha))] + (and (= 201 create-blob-status) + (= 200 get-blob-status) + (= "base64" (:encoding get-blob-body)) + (Arrays/equals contents (base64/decode-str->bytes (:content get-blob-body))))))) + +(defspec create-and-get-string-blob + (prop/for-all + [{:keys [handler org0 repo0]} (mock-gen/database {:repo [[1]]}) + contents gen/string] + (let [{create-blob-status :status + {blob-sha :sha} :body} (handler (create-string-blob-request (:org/name org0) (:repo/name repo0) contents)) + {get-blob-status :status + get-blob-body :body} (handler (get-blob-request (:org/name org0) (:repo/name repo0) blob-sha))] + (and (= 201 create-blob-status) + (= 200 get-blob-status) + (= "base64" (:encoding get-blob-body)) + (= contents (base64/decode-str->str (:content get-blob-body))))))) + (defspec get-content-supports-refs (prop/for-all [{:keys [handler org0 repo0 file branch]} (gen/let [{:keys [repo0] :as database} (mock-gen/database {:repo [[1]]}) @@ -294,6 +340,7 @@ (= {:status 200 :body {:type "file" :path (:path file) + :encoding "base64" :content (base64/encode-str->str (:content file))}} (handler (get-content-request (:org/name org0) (:repo/name repo0) (:path file) (:name branch)))))) @@ -307,5 +354,6 @@ (= {:status 200 :body {:type "file" :path (:path file) + :encoding "base64" :content (base64/encode-str->str (:content file))}} (handler (get-content-request (:org/name org0) (:repo/name repo0) (:path file)))))) diff --git a/test/clj_github_mock/impl/base64_test.clj b/test/clj_github_mock/impl/base64_test.clj new file mode 100644 index 0000000..00b181b --- /dev/null +++ b/test/clj_github_mock/impl/base64_test.clj @@ -0,0 +1,46 @@ +(ns clj-github-mock.impl.base64-test + (:require [clj-github-mock.impl.base64 :as base64] + [clojure.java.io :as io] + [clojure.test :refer :all] + [clojure.test.check.clojure-test :refer [defspec]] + [clojure.test.check.generators :as gen] + [clojure.test.check.properties :as prop]) + (:import (java.util Arrays))) + +(def test-cases + [{:data "" + :encoded ""} + + {:data "Hello world" + :encoded "SGVsbG8gd29ybGQ="} + + {:data "Eclipse Public License - v 2.0\n\n THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE\n PUBLIC LICENSE (\"AGREEMENT\"). ANY USE, REPRODUCTION OR DISTRIBUTION\n OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.\n\n1. DEFINITIONS\n\n\"Contribution\" means:\n\n" + :encoded "RWNsaXBzZSBQdWJsaWMgTGljZW5zZSAtIHYgMi4wCgogICAgVEhFIEFDQ09N\nUEFOWUlORyBQUk9HUkFNIElTIFBST1ZJREVEIFVOREVSIFRIRSBURVJNUyBP\nRiBUSElTIEVDTElQU0UKICAgIFBVQkxJQyBMSUNFTlNFICgiQUdSRUVNRU5U\nIikuIEFOWSBVU0UsIFJFUFJPRFVDVElPTiBPUiBESVNUUklCVVRJT04KICAg\nIE9GIFRIRSBQUk9HUkFNIENPTlNUSVRVVEVTIFJFQ0lQSUVOVCdTIEFDQ0VQ\nVEFOQ0UgT0YgVEhJUyBBR1JFRU1FTlQuCgoxLiBERUZJTklUSU9OUwoKIkNv\nbnRyaWJ1dGlvbiIgbWVhbnM6Cgo="} + + {:data (.readAllBytes (io/input-stream (io/resource "github-mark.png"))) + :encoded (slurp (io/resource "github-png-base64"))}]) + +(deftest base64-tests + (doseq [{:keys [data encoded]} test-cases] + (testing "encoding" + (let [encoder (if (bytes? data) + base64/encode-bytes->str + base64/encode-str->str)] + (is (= encoded (encoder data))))) + + (testing "decoding" + (let [decoder (if (bytes? data) + base64/decode-str->bytes + base64/decode-str->str) + checker (if (bytes? data) + ^[bytes bytes] Arrays/equals + =)] + (is (checker data (decoder encoded))))))) + +(defspec any-byte-array-roundtrips + (prop/for-all [^bytes bs gen/bytes] + (Arrays/equals bs (base64/decode-str->bytes (base64/encode-bytes->str bs))))) + +(defspec any-string-roundtrips + (prop/for-all [s gen/string] + (= s (base64/decode-str->str (base64/encode-str->str s))))) diff --git a/test/clj_github_mock/impl/jgit_test.clj b/test/clj_github_mock/impl/jgit_test.clj index c6f43fe..52c426d 100644 --- a/test/clj_github_mock/impl/jgit_test.clj +++ b/test/clj_github_mock/impl/jgit_test.clj @@ -27,7 +27,8 @@ (prop/for-all [^bytes content gen/bytes] (let [repo (sut/empty-repo) - {:keys [sha]} (sut/create-blob! repo {:content content})] + {:keys [sha]} (sut/create-blob! repo {:content (base64/encode-bytes->str content) + :encoding "base64"})] (Arrays/equals content (base64/decode-str->bytes (:content (sut/get-blob repo sha))))))) @@ -132,6 +133,7 @@ {:keys [sha]} (sut/create-commit! repo {:tree tree-sha :message "test" :parents []})] (every? #(= {:type "file" :path (:path %) + :encoding "base64" :content (base64/encode-str->str (:content %))} (sut/get-content repo sha (:path %))) tree)))) diff --git a/test/github-mark.png b/test/github-mark.png new file mode 100644 index 0000000..aebc182 Binary files /dev/null and b/test/github-mark.png differ diff --git a/test/github-png-base64 b/test/github-png-base64 new file mode 100644 index 0000000..f527599 --- /dev/null +++ b/test/github-png-base64 @@ -0,0 +1,30 @@ +iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGP +C/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3Cc +ulE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABK +ARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAWgA +AAABAAABaAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAAB +AAAAEAAAAAA+UMZWAAAACXBIWXMAADddAAA3XQEZgEZdAAABWWlUWHRYTUw6 +Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpu +czptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJE +RiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRm +LXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91 +dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUu +Y29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8 +L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgog +ICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoZXuEHAAACpUlEQVQ4EX1Tz0tU +URQ+5973xjIIWgy1EDIbbWzE1BQ0N9POoh8YSKvIsKWrCNpIi7b9AS1LaBMu +wnAX4gP7AUnoqOMozjS2kWKEiAiZmXvP6ZyXA27qDO++884537nf/c4dhAPL +ZrNBFEVOP8909p4ODA8TcRsAsjFQdoTvy4Xlr5o/XIuHA62ZzKmAgqcSu22s +DTXXMPK+Lv4rb/3D8vr690YTbDgdmZ4B2XEhDBPH6vWa4vblseqIeXmOSg4k +95sMXv6SX1lSbMxAKVugbQAWAL6R4ivW2iYiUjAYY8ALAymeY4BrGvNg2vVI +Rj8s+GdBGCrlxWIhNwoIF8nRPSa8xExDztG4NBnYLuRuAcO81ipGsdh2rrff +Wl4CRGDiz8XCSr8m/mWp8xfeIZphZgbyOBCgoRFEK43lhzytwEwmk0gmkxRF +SWGsqlewUqmYfD5fA+bnwnDYoAE2fkRF6JAAsCeynt4qQAp1nH8FECeKZBEp +dAnYzDuiKopGijWyNMZlOLRHtOh/RuS1Jp6OYg0j7AotUVpizIMKbmkZapJX +vKN+i+FBDGSTQRE0UIxiMZXpvmHAzsrIdPga7i8VVtdjmAAP3rEW7V19nez9 +gsRO6mgJ/E0twFRnd0Wm8EH8ZWuDx865RWR6VNxc+6gNJN+HgFPS5arU6dmB +mHdKXR0ppcly3SfCIHEdGT957yZkoM2Edk/BsaFkrB0VPyEca7q72AOYmfEm +K9exuLE2W3e1J0J4Tgj9QmPun2i2O1qlFnLwTf4LP1BMGCacp6lSIfcaxsZs +44yqqj+b7rkr430RBAFUq7V0eWt1Sxuk0z2tZLEsOv2UTSaLG7mXElYa1FDa +a7fS5sq09fvHnavfSUB1V8Fq1tb3vOfxJuPaYrDUSji+J38AqR4yTd6zmh4A +AAAASUVORK5CYII= \ No newline at end of file