Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/clojure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,20 @@ jobs:
distribution: temurin
java-version: ${{ matrix.java-version }}

- name: Print java version
- name: Setup Clojure
uses: DeLaGuardo/[email protected]
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
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ jobs:
distribution: temurin
java-version: ${{ matrix.java-version }}

- name: Setup Clojure
uses: DeLaGuardo/[email protected]
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

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ pom.xml.asc
.lein-failures
.nrepl-port
.cpcache/
*.iml
.idea
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
67 changes: 43 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
4 changes: 2 additions & 2 deletions project.clj
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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]]
Expand Down
57 changes: 44 additions & 13 deletions src/clj_github/test_helpers.clj
Original file line number Diff line number Diff line change
@@ -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))
127 changes: 82 additions & 45 deletions test/clj_github/test_helpers_test.clj
Original file line number Diff line number Diff line change
@@ -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")])))))