diff --git a/.github/workflows/clojure.yml b/.github/workflows/clojure.yml index b5f85a7..f63d3a0 100644 --- a/.github/workflows/clojure.yml +++ b/.github/workflows/clojure.yml @@ -24,7 +24,20 @@ jobs: distribution: temurin java-version: ${{ matrix.java-version }} - - name: Print java version + - name: Setup Clojure + uses: DeLaGuardo/setup-clojure@13.2 + with: + lein: 2.9.1 + + - name: Cache Clojure dependencies + uses: actions/cache@v4 + with: + path: | + ~/.m2/repository + # List all files containing dependencies: + key: cljdeps-${{ hashFiles('project.clj') }} + + - name: Print Java version run: java -version - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51dbb10..0e84f83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,19 @@ jobs: distribution: temurin java-version: ${{ matrix.java-version }} + - name: Setup Clojure + uses: DeLaGuardo/setup-clojure@13.2 + with: + lein: 2.9.1 + + - name: Cache Clojure dependencies + uses: actions/cache@v4 + with: + path: | + ~/.m2/repository + # List all files containing dependencies: + key: cljdeps-${{ hashFiles('project.clj') }} + - name: Print java version run: java -version diff --git a/.gitignore b/.gitignore index a4cb69a..8ce5212 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ pom.xml.asc .lein-failures .nrepl-port .cpcache/ +*.iml +.idea diff --git a/CHANGELOG.md b/CHANGELOG.md index bade0ad..be857b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.8.0 + +- Revise the `with-fake-github` macro to support all the options of the underlying `with-fake-http` macro + ## 0.7.1 - Fix decoding bug introduced in 0.7.0 diff --git a/README.md b/README.md index 0257f8a..4c995e5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # clj-github -A Clojure library for interacting with the github developer API. -Note: while this library is being used for several production use cases, we're still ironing out the APIs, so they are subject to change. +[![Clojars Project](https://img.shields.io/clojars/v/dev.nubank/clj-github.svg)](https://clojars.org/dev.nubank/clj-github) +[![cljdoc badge](https://cljdoc.org/badge/dev.nubank/clj-github)](https://cljdoc.org/d/dev.nubank/clj-github) -## Httpkit client +A Clojure library for interacting with the GitHub REST API. +## Httpkit client *Example*: ```clojure @@ -82,37 +83,58 @@ For example to change the contents of a file and commit them to a new branch, on ### Helpers The `clj-github.test-helpers` provides a macro that can be used to mock -calls to github. +calls to the GitHub REST API. Example: ```clojure -(with-fake-github ["/repos/nubank/clj-github/pulls/1" {:body (misc/write-json {:attr "value"})}] +(with-fake-github ["/repos/nubank/clj-github/pulls/1" {:body (cheshire.core/generate-string {:attr "value"})}] (github-client/request (github-client/new-client) {:path "/repos/nubank/clj-github/pulls/1"})) ``` + +`with-fake-github` is a wrapper around the [`httpkit.fake`](https://github.com/d11wtq/http-kit-fake) library, +specifically the `org.httpkit.fake/with-fake-http` macro. + +As with `with-fake-http` the behavior is specified as request and response pairs. +The request forms are used to identify which requests are matched against which response forms. -The macro receives pairs of `request` and `response` forms. +A request form may be one of: -A request can be a: +* `String`: sets the _path_ of the endpoint to match +* `Map`: a complete request map specificiation; all keys in the map must exactly match the corresponding keys of the request map +* `regex`: matches if the reqex matches the :url of the request +* `function`: matches if the function, passed the request map, returns truthy -* `String`: it should contain an endpoint that you want to match. The value should only -contain the path of the endpoint, without the `https://api.github.com` url. -* `Map`: it can contain a complete request map specification. -* `regex`: the request will match if its url matches the request. -* `function`: a function with one argument, the request map is passed to the function, -if the function returns a truthy value, the request will match. +With a Map, the :path (if present) is prefixed to form the final :url attribute. -Provided a request is matched, the corresponding response will be returned. A response can -be a: +Note: normally, if you compute the value of the path, e.g., `(str "/repos/" repo-name "/pulls/" pull-number)`, the +computed value will be passed to `with-fake-http` and interpretted as the _URL_. Apply the meta-data :path to the +expression so that `with-fake-github` can treat the computed value the same as a literal string: as the path from which +the URL is computed. Example: `^:path (str "/repos/" repo-name "/pulls/" pull-number)`. -* `String`: it will be returned as the body of the response, the response will have a status 200. +Provided a request is matched, a full response is generated from the corresponding response form. + +A response form can be one of: + +* `String`: it will be returned as the body of the response, the response will have a status 200 * `Map`: it will be returned as the response, some values are automatically added as default -(e.g. status will be 200 if not specified). -* `Integer`: a response with the given status code will be returned. +(e.g. status will be 200 if not specified) +* `Integer`: a response with the given status code will be returned +* `function`: See the `with-fake-http` macro documentation for this advanced usage -Notes: +In addition, the values :allow and :deny are supported. + +The response content type `application/json` is automatically applied. +Response bodies must be JSON values encoded as strings, which will be parsed back to EDN data. + +Example: +```clojure +(deftest response-may-be-a-JSON-encoded-string + (is (match? {:status 200 + :body {:solution 42}} + (with-fake-github ["/api/answer" (json/generate-string {:solution 42})] + (request "/api/answer"))))) +``` -* The macro does not work with the http client component. You can just its own mocking facility. -* The macro is based on [`httpkit.fake`](https://github.com/d11wtq/http-kit-fake) library, to make it work you need to add it as a development dependency of your project. You can look at `httpkit.fake documentation` for more clear explanation of how to specify requests and responses. ### Running tests @@ -122,6 +144,3 @@ To run tests you can use lein test ``` -## examples - -If you'd like an example of this library in action, check out [`ordnungsamt`](https://github.com/nubank/ordnungsamt), which is a tool for applying ad-hoc code migrations to a set of github repositories and subsequently opening pull requests for them. diff --git a/project.clj b/project.clj index b5ec9a6..b2b81d8 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject dev.nubank/clj-github "0.7.1" +(defproject dev.nubank/clj-github "0.8.0" :description "A Clojure library for interacting with the github developer API" :url "https://github.com/nubank/clj-github" :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" @@ -24,7 +24,7 @@ ; Dependencies required by clj-github.test-helpers and clj-github.state-flow-helper. ; Must be provided by the user (typically only used in tests) [http-kit.fake "0.2.2" :scope "provided"] - [nubank/state-flow "5.18.0" :scope "provided"] + [nubank/state-flow "5.20.1" :scope "provided"] [dev.nubank/clj-github-mock "0.4.0" :scope "provided"]] :cljfmt {:indents {flow [[:block 1]] diff --git a/src/clj_github/test_helpers.clj b/src/clj_github/test_helpers.clj index dea0af7..ece2490 100644 --- a/src/clj_github/test_helpers.clj +++ b/src/clj_github/test_helpers.clj @@ -1,40 +1,71 @@ (ns clj-github.test-helpers (:require [clj-github-app.token-manager] - [org.httpkit.fake :refer [with-fake-http]])) + [clj-github.httpkit-client :refer [github-url]] + [org.httpkit.fake :as fake]) + (:import (java.util.regex Pattern))) + (defn- spec-type [spec] (cond + (-> spec meta :path) :path (string? spec) :string (map? spec) :map + (instance? Pattern spec) :pattern :else :form)) (defmulti spec-builder spec-type) (defmethod spec-builder :string [request] - (str "https://api.github.com" request)) + (str github-url request)) -(defmethod spec-builder :map [{:keys [path] :as request}] - (if path - (assoc request :url (str "https://api.github.com" path)) +(defmethod spec-builder :map [request] + (if (:path request) + `(let [request# ~request + path# (:path request#)] + (assoc request# :url (str github-url path#))) request)) (defmethod spec-builder :form [request] request) +(defmethod spec-builder :path [form] + `(spec-builder ~form)) + +(defmethod spec-builder :pattern [pattern] + {:url pattern}) + +(defn -add-default-content-type + [response] + (assoc-in response [:headers :content-type] "application/json")) + (defn add-default-content-type [response] - (if (map? response) - (update-in response [:headers :content-type] #(or % "application/json")) - {:body response - :headers {:content-type "application/json"}})) + `(let [responder# (fake/responder ~response)] + (fn [origin-fn# opts# callback#] + ((or callback# identity) + (responder# origin-fn# opts# -add-default-content-type))))) (defn build-spec [spec] (reduce (fn [processed-fakes [request response]] (-> processed-fakes (conj (spec-builder request)) (conj (add-default-content-type response)))) - ["https://api.github.com/app/installations" "{}"] + [(str github-url "app/installations") "{}"] (partition 2 spec))) -(defmacro with-fake-github [spec & body] - `(with-fake-http ~(build-spec spec) - ~@body)) +(defmacro with-fake-github + "A wrapper around `with-fake-http` that sets up some defaults for GitHub access. + + `with-fake-http` is organized with the expectation that request and response specs + are values; any function calls used to generate the specs look to it as if they are + function values that will (for requests) match or (for responses) build the response from + the request map. + + The ^:path metadata may precede a form to indicate that the form is an expression + that computes the path. + + The response may be any value supported by `with-fake-http` (map, integer, string, function, etc.). + However, the response Content-Type header is forced to \"application/json\", so the body (if provided) + must be a JSON value encoded as string." + [spec & body] + `(fake/with-fake-http ~(build-spec spec) + ~@body)) diff --git a/test/clj_github/test_helpers_test.clj b/test/clj_github/test_helpers_test.clj index cc04cf5..5484d43 100644 --- a/test/clj_github/test_helpers_test.clj +++ b/test/clj_github/test_helpers_test.clj @@ -1,50 +1,87 @@ (ns clj-github.test-helpers-test - (:require [clojure.test :refer :all] + (:require [cheshire.core :as json] + [clojure.test :refer [deftest is]] [clj-github.httpkit-client :as httpkit-client] - [clj-github.test-helpers :as sut] + [clj-github.test-helpers :refer [with-fake-github]] [matcher-combinators.test])) -(deftest with-fake-github-test - (let [client (httpkit-client/new-client {:token-fn (fn [] "token")})] - (testing "it appends github url when request is a string" - (is (match? {:status 200} - (sut/with-fake-github ["/api/repos" {:status 200}] - (httpkit-client/request client {:path "/api/repos"}))))) - (testing "it supports a path attribute in a request map" - (is (match? {:status 200} - (sut/with-fake-github [{:path "/api/repos"} {:status 200}] - (httpkit-client/request client {:path "/api/repos"}))))) - (testing "it supports regexes" - (is (match? {:status 200} - (sut/with-fake-github [#"/api/repos" {:status 200}] - (httpkit-client/request client {:path "/api/repos"}))))) - - (testing "it supports functions as request spec" - (let [request-fn (fn [request] - (re-find #"/api/repos" (:url request))) - request-fn-with-arg (fn [url-regex] - (fn [request] - (re-find url-regex (:url request))))] - (is (match? {:status 200} - (sut/with-fake-github ["/other" "{}" - (request-fn-with-arg #"/api/whatever") {:status 200}] - (httpkit-client/request client {:path "/other"}) - (httpkit-client/request client {:path "/api/whatever"})))) - - (is (match? {:status 200} - (sut/with-fake-github ["/other" "{}" - request-fn {:status 200}] - (httpkit-client/request client {:path "/other"}) - (httpkit-client/request client {:path "/api/repos"})))))) - - (testing "it adds content-type application/json by default if no content-type is provided" - (is (match? {:status 200 :body {:number 2}} - (sut/with-fake-github ["/other" "{\"number\": 2}"] - (httpkit-client/request client {:path "/other"}))))) - - (testing "it maintains the content-type if one is provided" - (is (match? {:status 200 :body "{\"number\": 2}"} - (sut/with-fake-github ["/other" {:body "{\"number\": 2}" - :headers {:content-type "text/html"}}] - (httpkit-client/request client {:path "/other"}))))))) +(def ^:private client (httpkit-client/new-client {:token-fn (fn [] "token")})) +(defn- request [path] + (httpkit-client/request client {:path path + :throw? false})) + +(deftest appends-github-url-when-request-is-string + (is (match? {:status 200 + :opts {:url "https://api.github.com/api/repos"}} + (with-fake-github ["/api/repos" 200] + (request "/api/repos"))))) + +(deftest response-may-be-a-number + (is (match? {:status 418} + (with-fake-github ["/api/teapot" 418] + (request "/api/teapot"))))) + +(deftest response-may-be-a-JSON-encoded-string + ;; Because we force the response content type to application/json, the JSON is + ;; parsed back to EDN in the response body. + (is (match? {:status 200 + :body {:solution 42}} + (with-fake-github ["/api/answer" (json/generate-string {:solution 42})] + (request "/api/answer"))))) + +(deftest supports-computed-string + (let [last-term "bar"] + (is (match? {:status 200 + :body 1234} + (with-fake-github [^:path (str "/api/repos/" last-term) "1234"] + (request "/api/repos/bar")))))) + +(deftest response-may-be-a-map + (is (match? {:status 201 + :body {:solution 42}} + (with-fake-github ["/api/answer" {:status 201 + :body (json/generate-string {:solution 42})}] + (request "/api/answer"))))) + +(deftest supports-computed-path-key + (let [last-term "foo"] + (is (match? {:status 200} + (with-fake-github [{:path (str "/api/repos/" last-term)} 200] + (request "/api/repos/foo")))))) + +(deftest supports-path-attribute-in-request-map + (is (match? {:status 200} + (with-fake-github [{:path "/api/repos"} 200] + (request "/api/repos"))))) + +(deftest supports-regexps-as-spec + (is (match? {:status 200} + (with-fake-github [#"/api/repos" 200] + (request "/api/repos"))))) + +(deftest supports-computed-regexps-as-spec + (let [last-term "fred"] + (is (match? {:status 200} + (with-fake-github [(re-pattern (str ".api/repos/" last-term)) 200] + (request "/api/repos/fred/and-more")))))) + +(deftest supports-functions-as-spec + (let [request-fn (fn [request] + (re-find #"/api/repos" (:url request))) + request-fn-with-arg (fn [url-regex] + (fn [request] + (re-find url-regex (:url request))))] + (is (match? [{:status 418} + {:status 200}] + (with-fake-github ["/other" 418 + (request-fn-with-arg #"/api/whatever") 200] + [(request "/other") + (request "/api/whatever")]))) + + (is (match? [{:status 418} + {:status 200}] + (with-fake-github ["/other" 418 + request-fn 200] + [(request "/other") + (request "/api/repos")])))))